Compare commits

..
25 Commits
Author SHA1 Message Date
ypsdf1 b79754039f chore: 移除非项目文件,添加gitignore规则
- 从仓库追踪中移除txt/、工作簿1.xlsx、unnamed.patch等本地开发文件
- 更新.gitignore排除这些文件类型
2026-05-14 21:08:55 +08:00
ypsdf1 63d0a99cff Remove: 删除误传的radiopack.zip 2026-05-14 21:03:41 +08:00
ypsdf1 8b73031ef0 Update: 添加RadioManager/RadioDownloadListener/UpdateChecker/WelcomeManager等新功能模块 2026-05-14 21:03:03 +08:00
ypsdf1 677fea7de1 Remove: 删除英文README 2026-05-11 13:45:28 +08:00
ypsdf1 87de2e12c8 修复:垃圾箱物品存入立即吞噬的问题和服务器没启用飞行功能被踢出的问题 2026-05-11 13:26:55 +08:00
ypsdf1 145b392112 Add: 完整的README文档和截图 2026-05-10 17:22:25 +08:00
ypsdf1 f188061527 Remove: 删除所有README和图片 2026-05-10 17:22:02 +08:00
ypsdf1 b23f3be25f Add: 英文README和截图 2026-05-10 17:17:52 +08:00
ypsdf1 c5701312db Add: 中文README (使用Python写入,UTF-8) 2026-05-10 17:17:34 +08:00
ypsdf1 319654d76d Add: README (verification test) 2026-05-10 17:17:08 +08:00
ypsdf1 9ee751631b Remove: 删除损坏的README 2026-05-10 17:16:42 +08:00
ypsdf1 80b79c5e16 Add: 添加中英文README和截图(UTF-8) 2026-05-10 17:16:17 +08:00
ypsdf1 2a08a4ed9b Remove: 删除损坏的README 2026-05-10 17:15:34 +08:00
ypsdf1 ef6d43eadd Add: 重新添加图片(使用.gitattributes) 2026-05-10 17:13:19 +08:00
ypsdf1 8c5d4d3c15 Drop: 删除损坏的图片 2026-05-10 17:12:58 +08:00
ypsdf1 f1e68fd55b Add: 添加gitattributes强制二进制处理图片 2026-05-10 17:12:36 +08:00
ypsdf1 9637f0bd53 Add: 添加中英文README和截图 2026-05-10 17:09:40 +08:00
ypsdf1 45b547ed96 Delete: 移除损坏的README和img 2026-05-10 17:09:12 +08:00
ypsdf1 7d3affb83c Fix: 修复README图片路径 2026-05-10 17:07:18 +08:00
ypsdf1 b3fa97b7e5 Update: 添加截图并修复README图片路径 2026-05-10 17:06:48 +08:00
ypsdf1 16436354eb Update: 添加中英文README,提交所有代码修改 2026-05-10 17:01:15 +08:00
ypsdf1 48b31deaac 修复:登陆后物品无法还原的bug 2026-05-07 00:24:15 +08:00
ypsdf1 68bcbe2e41 Merge branch 'main' of https://github.com/ypsdf1/sdf1_login 2026-05-06 17:29:09 +08:00
ypsdf1 8454df7e75 初始化Sdf1_login项目 2026-05-06 17:28:50 +08:00
ypsdf1 c80afb8069 Initial commit 2026-05-04 10:36:06 +08:00
112 changed files with 16720 additions and 55390 deletions
+5
View File
@@ -0,0 +1,5 @@
*.md text=auto encoding=UTF-8
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
+42 -60
View File
@@ -1,63 +1,45 @@
*.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 永不上传它,避免覆盖线上真值。
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
.kotlin
# 开发辅助文档(本地保留,不推送到仓库)
/代码关联关系.md
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
# ===== 支付密钥 - 严禁提交 (double insurance) =====
# 彩虹易支付 RSA 私钥/公钥/商户ID 全部在此文件,一旦进 git 或进公网 web 根目录即泄露
密钥文件_git拉黑.txt
pay_secrets.php
*.paykey
# 屏蔽无用的db文件夹(仓库不需要db文件)
db/
/*.rar
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
# 屏蔽所有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/
### 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
+10
View File
@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
</project>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
-661
View File
@@ -1,661 +0,0 @@
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/>.
+112
View File
@@ -0,0 +1,112 @@
# Sdf1系列登录插件使用说明
## 1. 功能介绍
| 名字 | 描述/作用 | 施工状态 |
| :--- | :--- | :--- |
| 登录 | 用户登录/注册 | 已完成 |
| 世界垃圾箱 | 清理服务器凋落物 | 半成品 |
| 工单系统 | 用户提交工单 | 已完成 |
| 签到系统 | 签到积分换奖励 | 已完成 |
## 2. 安装说明
| 前置插件 | 选装类型 |
| :--- | :--- |
| CY_beibao | 可选 |
| EssentialsX | 可选 |
### 2.1 下载
您可以从GitHub和Gitee下载插件,将其放入服务器plugin目录,重启服务器即可。
### 2.2 配置文件
#### 2.2.1 消息.txt
此文件控制整个插件的所有提示消息,修改后执行 /sdf1_login reload 重载插件。
#### 2.2.2 挂机白名单.txt
挂机踢出开启时,该配置下的玩家不受影响。
#### 2.2.3 SMTP设置.txt
控制SMTP邮件服务器配置,用于找回密码时发送邮件。
#### 2.2.4 插件设置.txt
管控整个插件,包括账号注册限制、管理员密码等。
#### 2.2.5 chat.txt
控制聊天过滤,处理违规链接。
## 3. 指令
- Sdf1_login 主指令
- Sdf1_login reload 重载配置
- reg 注册账号
- login 登录账号
- l 登录账号
## 4. 运行截图
主界面
![主界面](img/login.png)
邀请数据
![邀请数据](img/login2.png)
积分商店
![积分商店](img/login3.png)
任务中心
![任务中心](img/login4.png)
垃圾箱
![垃圾箱](img/login5.png)
管理员面板
![管理员面板](img/login6.png)
管理面板-用户管理
![用户管理](img/login7.png)
工单中心
![工单中心](img/login8.png)
工单管理
![工单管理](img/login9.png)
我的工单
![我的工单](img/login10.png)
提交工单
![提交工单](img/login11.png)
服务商列表
![服务商列表](img/login12.png)
服务商抢单大厅
![抢单大厅](img/login13.png)
服务商-接单界面
![接单界面](img/login14.png)
管理员-工单详情
![工单详情](img/login15.png)
## 5. 下载地址
- GitHub: https://github.com/ypsdf1/sdf1_login
- Gitee: https://gitee.com/nihaoshidifu/sdf1_login
-146
View File
@@ -1,146 +0,0 @@
<?php
require_once __DIR__ . '/core.php';
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
if (session_status() === PHP_SESSION_NONE) session_start();
$isApi = (getParam('action') === 'data');
function getActivePlayers() {
$online = []; $count = 0;
try {
$db = getDB();
// 确保表存在
$db->exec("CREATE TABLE IF NOT EXISTS hourly_active_players (player_name TEXT PRIMARY KEY, first_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL, hour_key TEXT NOT NULL)");
$db->exec("CREATE TABLE IF NOT EXISTS online_players (player_name TEXT PRIMARY KEY, login_time INTEGER DEFAULT 0)");
$currentHourKey = date('Y-m-d H');
// ★ 查询本小时活跃过的所有玩家(包括已离线但本小时曾上线的)
$res = $db->query("SELECT player_name, first_seen, last_seen FROM hourly_active_players WHERE hour_key = '" . SQLite3::escapeString($currentHourKey) . "' ORDER BY first_seen ASC");
$now = time();
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
// 检查玩家当前是否在线(从online_players表判断)
$checkStmt = $db->prepare("SELECT 1 FROM online_players WHERE player_name = :name");
$checkStmt->bindValue(':name', $row['player_name'], SQLITE3_TEXT);
$checkRes = $checkStmt->execute();
$isOnline = $checkRes->fetchArray() !== false;
$online[] = [
'name' => $row['player_name'],
'login_time' => (int)$row['first_seen'],
'login_time_fmt' => $row['first_seen'] > 0 ? date('H:i:s', (int)$row['first_seen']) : '-',
'duration' => $row['first_seen'] > 0 ? $now - (int)$row['first_seen'] : 0,
'is_online' => $isOnline,
'last_seen' => (int)$row['last_seen'],
];
}
$count = count($online);
} catch (\Throwable $e) {
debugLog('[active_players] 查询异常: ' . $e->getMessage());
}
return ['online_list' => $online, 'online_count' => $count];
}
if ($isApi) {
success(getActivePlayers());
exit;
}
$initialState = json_encode(getActivePlayers(), JSON_UNESCAPED_UNICODE);
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate">
<title>SDF1 - 在线活跃</title>
<style>
:root { --bg:#0d1117; --card:#161b22; --border:#30363d; --text:#e6edf3; --dim:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d29922; --purple:#bc8cff; }
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:var(--text); font-family:'Segoe UI',system-ui,sans-serif; min-height:100vh; }
.header { background:linear-gradient(135deg,#1a1e2e,#0d1117); border-bottom:1px solid var(--border); padding:14px 24px; display:flex; justify-content:space-between; align-items:center; position:sticky; top:0; z-index:50; }
.header h1 { font-size:18px; color:var(--accent); }
.header .info { font-size:13px; color:var(--dim); }
.container { max-width:900px; margin:20px auto; padding:0 16px; }
/* 在线玩家卡片网格 */
.grid-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; flex-wrap:wrap; gap:8px; }
.grid-header h2 { font-size:15px; color:var(--accent); }
.grid-header .badge { background:var(--green); color:#000; padding:4px 12px; border-radius:20px; font-size:13px; font-weight:700; }
.player-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:12px; }
.player-card { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:14px 16px; transition:border-color .2s; }
.player-card:hover { border-color:var(--accent); }
.player-card.offline { opacity:0.55; border-style:dashed; }
.player-card .name { font-size:15px; font-weight:700; color:var(--text); margin-bottom:4px; display:flex; align-items:center; gap:6px; }
.player-card .name .status-dot { width:8px; height:8px; border-radius:50%; display:inline-block; flex-shrink:0; }
.player-card .name .status-dot.on { background:var(--green); box-shadow:0 0 6px var(--green); }
.player-card .name .status-dot.off { background:var(--dim); }
.player-card .meta { font-size:12px; color:var(--dim); display:flex; justify-content:space-between; }
.player-card .meta .online-tag { color:var(--green); }
.player-card .meta .offline-tag { color:var(--yellow); }
.player-card .time-bar { margin-top:8px; height:4px; background:var(--bg); border-radius:4px; overflow:hidden; }
.player-card .time-bar .fill { height:100%; background:var(--green); border-radius:4px; transition:width 1s; }
.empty { grid-column:1/-1; text-align:center; padding:40px; color:var(--dim); }
.refresh-hint { text-align:center; font-size:12px; color:var(--dim); margin-top:16px; }
#loading { text-align:center; padding:40px; color:var(--dim); }
@media(max-width:500px) {
.player-grid { grid-template-columns:1fr; }
}
</style>
</head>
<body>
<div class="header">
<h1>🟢 本小时在线活跃</h1>
<div class="info">每10秒刷新</div>
</div>
<div class="container">
<div id="loading">加载中...</div>
<div id="content" style="display:none">
<div class="grid-header">
<h2>👤 当前在线玩家</h2>
<span class="badge" id="countBadge">0 人</span>
</div>
<div class="player-grid" id="playerGrid"></div>
<div class="refresh-hint" id="refreshHint"></div>
</div>
</div>
<script>
const state = <?= $initialState ?>;
function fmtDuration(sec) {
if (sec < 60) return sec + '秒';
if (sec < 3600) return Math.floor(sec/60) + '分' + (sec%60) + '秒';
return Math.floor(sec/3600) + '时' + Math.floor((sec%3600)/60) + '分';
}
function render(data) {
document.getElementById('loading').style.display = 'none';
document.getElementById('content').style.display = 'block';
document.getElementById('countBadge').textContent = data.online_count + ' 人';
const grid = document.getElementById('playerGrid');
if (data.online_list.length === 0) {
grid.innerHTML = '<div class="empty">本小时暂无玩家活跃</div>';
} else {
// 在线玩家排前面,离线玩家排后面
const sorted = [...data.online_list].sort((a, b) => (b.is_online ? 1 : 0) - (a.is_online ? 1 : 0));
grid.innerHTML = sorted.map(p => {
const mx = 6 * 3600;
const pct = Math.min(100, Math.round(p.duration / mx * 100));
const cls = p.is_online ? 'player-card' : 'player-card offline';
const dot = p.is_online ? '<span class="status-dot on"></span>' : '<span class="status-dot off"></span>';
const tag = p.is_online
? `<span class="online-tag">在线 ${fmtDuration(p.duration)}</span>`
: `<span class="offline-tag">已离线</span>`;
return `<div class="${cls}"><div class="name">${dot}${esc(p.name)}</div><div class="meta"><span>首次:${p.login_time_fmt}</span>${tag}</div><div class="time-bar"><div class="fill" style="width:${pct}%;background:${p.is_online ? 'var(--green)' : 'var(--dim)'}"></div></div></div>`;
}).join('');
}
document.getElementById('refreshHint').textContent = '更新:' + new Date().toLocaleTimeString('zh-CN', {hour12:false});
}
function esc(s) { const d=document.createElement('div'); d.textContent=s||''; return d.innerHTML; }
render(state);
setInterval(() => {
fetch('active_players.php?action=data&_t=' + Date.now()).then(r=>r.json()).then(d => {
if (d.success) render(d.data);
}).catch(()=>{});
}, 10000);
</script>
</body>
</html>
-5058
View File
File diff suppressed because it is too large Load Diff
-174
View File
@@ -1,174 +0,0 @@
<?php
/**
* 第一层二次验证 —— 6 位动态码验证页
* 兼容 1Password / Microsoft Authenticator / Google Authenticator 等标准 RFC6238 认证器。
* 登录成功且 SEC_2FA_ENABLED=true 时,前端跳转到本页;验证通过后进入 admin.php。
*/
require_once __DIR__ . '/security.php';
secEnsureSession();
// IP 白名单闸门;boot 引导会话(配置流程中转)也放行
secGatePage(true);
// ★ boot 恢复模式(2026-10-05):持有 boot 凭据但 IP 不在白名单 → 直接送回引导配置页。
// 不加这一跳的话,下面的链路对非白名单 IP 全是 404 死胡同:
// 未登录 → 跳 admin_login.php → 404;已登录已过2FA → 跳 admin.php?token= → 404
// (线上 09:35:58 boot_unlock 后 1 秒就吃了 admin.php 的 ip_block,实录见 db/security.log)。
// 白名单校验本身没有被绕过 —— 本页对非白名单 IP 依旧不渲染,只是不再把人丢去 404。
if (!secIpAllowed() && secBootAuthenticate()) {
secBootRecoveryRedirect();
}
$enabled = defined('SEC_2FA_ENABLED') && SEC_2FA_ENABLED;
$err = '';
// 未启用 → 引导去配置页
if (!$enabled) {
header('Location: admin_2fa_setup.php');
exit;
}
// 未登录 / 旧纪元会话 → 回登录页
// (本页只依赖 security.php,不加载 core.php,故用等价内联判定)
if (empty($_SESSION['admin_auth']) || !secSessionEpochOk()) {
header('Location: admin_login.php');
exit;
}
// 已通过 → 直接进后台(★ 第三层:必须带上入口令牌,否则 admin.php 会返回 404)
if (!empty($_SESSION['admin_2fa_ok'])) {
header('Location: ' . secTokenUrl('admin.php'));
exit;
}
// ===== POST:校验动态码 =====
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$sid = session_id();
$lock = secThrottleLocked('twofa_fail', $sid, 5, 900);
if ($lock > 0) {
$err = '验证失败次数过多,已锁定,请 ' . $lock . ' 秒后再试';
secLog('2fa_throttled', 'lock=' . $lock . 's');
} else {
$code = isset($_POST['code']) ? (string)$_POST['code'] : '';
if (secTotpVerify(SEC_2FA_SECRET, $code)) {
$_SESSION['admin_2fa_ok'] = time();
$_SESSION['admin_2fa_ip'] = secClientIp();
secThrottleReset('twofa_fail', $sid);
secLog('2fa_pass', 'session granted');
header('Location: ' . secTokenUrl('admin.php'));
exit;
}
$left = secThrottleFail('twofa_fail', $sid, 5, 900);
// ★ 诊断:把"密钥录错/绑了旧密钥"和"设备时钟漂移"两种失败原因区分开,
// 否则用户只能看到一句"无效",无从下手。
$off = secTotpDiagnose(SEC_2FA_SECRET, $code);
secLog('2fa_fail', $left > 0 ? 'LOCKED_' . $left . 's'
: ($off === null ? 'wrong_secret' : 'clock_skew ' . $off . 's'));
$err = $left > 0
? '验证失败次数过多,已锁定,请 ' . $left . ' 秒后再试'
: secTotpDiagnoseMsg($off);
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SDF1 - 二次验证</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0d1117; color: #e6edf3;
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex; justify-content: center; align-items: center; min-height: 100vh;
}
.box {
background: #161b22; border: 1px solid #30363d; border-radius: 12px;
padding: 40px; width: 400px; max-width: 92%;
}
.box h1 { text-align: center; color: #58a6ff; margin-bottom: 8px; font-size: 22px; }
.box .sub { text-align: center; color: #8b949e; font-size: 13px; margin-bottom: 24px; line-height: 1.7; }
.code-input {
width: 100%; padding: 14px; background: #0d1117; border: 1px solid #30363d;
border-radius: 8px; color: #e6edf3; font-size: 30px; letter-spacing: 10px;
text-align: center; outline: none; font-family: monospace;
}
.code-input:focus { border-color: #58a6ff; }
.btn {
width: 100%; padding: 12px; background: #58a6ff; color: #fff; border: none;
border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; margin-top: 16px;
}
.btn:hover { background: #79c0ff; }
.timer-bar { height: 4px; background: #30363d; border-radius: 2px; margin-top: 14px; overflow: hidden; }
.timer-bar .fill { height: 100%; background: #3fb950; width: 100%; transition: width .3s linear; }
.timer-text { font-size: 11px; color: #8b949e; text-align: center; margin-top: 6px; }
.error {
color: #f85149; font-size: 13px; text-align: center; margin-top: 14px;
background: rgba(248,81,73,0.1); border: 1px solid rgba(248,81,73,0.3);
border-radius: 6px; padding: 8px; display: block;
}
.links { text-align: center; margin-top: 18px; }
.links a { color: #58a6ff; text-decoration: none; font-size: 13px; }
.ip { text-align: center; font-size: 11px; color: #8b949e; margin-top: 16px; }
.ip b { color: #d29922; font-family: monospace; }
</style>
</head>
<body>
<div class="box">
<h1>🛡️ 二次验证</h1>
<div class="sub">请输入认证器 App 中的 <b style="color:#e6edf3">6 位动态码</b><br>
(1Password / Microsoft Authenticator / Google Authenticator 均可)</div>
<form method="post" autocomplete="off">
<input class="code-input" id="code" name="code" inputmode="numeric" pattern="[0-9]*"
maxlength="6" placeholder="000000" autofocus>
<button class="btn" type="submit" id="submitBtn">验 证</button>
<div class="timer-bar"><div class="fill" id="tfill"></div></div>
<div class="timer-text" id="ttext">动态码每 30 秒刷新</div>
<div class="timer-text" id="srvtime" style="color:#58a6ff"></div>
<?php if ($err !== ''): ?>
<div class="error" style="white-space:pre-wrap;text-align:left"><?php echo htmlspecialchars($err, ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
</form>
<div class="links">
<a href="admin_login.php">← 返回登录</a> &nbsp;|&nbsp;
<a href="admin_2fa_setup.php">安全配置</a>
</div>
<div class="ip">当前访问 IP:<b><?php echo htmlspecialchars(secClientIp(), ENT_QUOTES, 'UTF-8'); ?></b></div>
<div class="ip" style="margin-top:4px">提示:认证器与上方<b>服务器时间</b>偏差超过 90 秒就会被拒</div>
</div>
<script>
(function() {
var input = document.getElementById('code');
input.addEventListener('input', function() {
this.value = this.value.replace(/\D/g, '').slice(0, 6);
});
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && this.value.length === 6) {
document.querySelector('form').submit();
}
});
// ★ 进度条/倒计时改用【服务器时钟】:
// 手机时间不准时,用浏览器自身时钟画进度条会误导用户(看着还剩 20 秒,
// 服务器那边其实已经翻到下一窗格了),所以以 PHP 渲染时刻为基准推进。
var srvBase = <?php echo time(); ?>; // 页面渲染时的服务器 Unix 时间
var srvOff = <?php echo (new DateTime('now'))->getOffset(); ?>; // 服务器时区偏移(秒)
var t0 = Date.now();
function srvNow() { return srvBase + Math.floor((Date.now() - t0) / 1000); }
function pad(n) { return (n < 10 ? '0' : '') + n; }
var fill = document.getElementById('tfill');
var text = document.getElementById('ttext');
var srvBox = document.getElementById('srvtime');
function tick() {
var s = srvNow();
var left = 30 - (s % 30);
fill.style.width = (left / 30 * 100) + '%';
var d = new Date((s + srvOff) * 1000);
text.textContent = left + ' 秒后动态码刷新';
srvBox.textContent = '服务器时间 ' + pad(d.getUTCHours()) + ':' + pad(d.getUTCMinutes()) + ':' + pad(d.getUTCSeconds())
+ '(与你手机差多少秒请自行比对)';
}
tick();
setInterval(tick, 300);
})();
</script>
</body>
</html>
-2934
View File
File diff suppressed because it is too large Load Diff
-133
View File
@@ -1,133 +0,0 @@
<?php
// ★ 紧急加固(2026-09-29):第二层 IP 白名单 —— 登录页本身也只对白名单 IP 开放
require_once __DIR__ . '/security.php';
secGatePage();
// ★ 首跑顺序(2026-10-09):装库先于安全配置 —— install.php 还没跑完就先去装库。
// 引导模式下 secGatePage 已先行 302;这里兜住的是「本机 127.0.0.1 直连」与
// 「已引导但装库没做过」两种入口。secTokenUrl 在令牌未配置时原样返回
// install.php,配了令牌则带上 ?token= 过第三层门禁(与 admin.php 同款拼法)。
if (secInstallPending()) {
header('Location: ' . secTokenUrl('install.php'));
exit;
}
// ★ 首次部署:管理密码还是分发占位符 → 登录页是个死胡同(要密码,可密码还不存在)。
// 改投初始化页,让部署者先把自己的第一个密码设出来。
if (!secAdminPassConfigured()) {
header('Location: admin_2fa_setup.php');
exit;
}
// ★ 人机验证(2026-10-02):cgWidgetHtml() 渲染挂件,未配密钥时自动放行
require_once __DIR__ . '/captcha_guard.php';
// ★ 数据安全护栏横幅(2026-10-10):库被清空/指错库时,管理员一登录就能看到告警
require_once __DIR__ . '/db_guard.php';
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SDF1 - 管理后台登录</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0d1117; color: #e6edf3;
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex; justify-content: center; align-items: center;
min-height: 100vh;
}
.login-box {
background: #161b22; border: 1px solid #30363d; border-radius: 12px;
padding: 40px; width: 380px; max-width: 90%;
}
.login-box h1 { text-align: center; color: #58a6ff; margin-bottom: 8px; font-size: 24px; }
.login-box .sub { text-align: center; color: #8b949e; font-size: 13px; margin-bottom: 28px; }
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 13px; color: #8b949e; margin-bottom: 6px; }
.field input {
width: 100%; padding: 10px 14px; background: #0d1117; border: 1px solid #30363d;
border-radius: 6px; color: #e6edf3; font-size: 14px; outline: none; transition: border-color 0.2s;
}
.field input:focus { border-color: #58a6ff; }
.login-btn {
width: 100%; padding: 12px; background: #58a6ff; color: #fff; border: none;
border-radius: 6px; font-size: 15px; font-weight: 600; cursor: pointer;
transition: background 0.2s; margin-top: 8px;
}
.login-btn:hover { background: #79c0ff; }
.login-btn:disabled { background: #30363d; cursor: not-allowed; }
.error { color: #f85149; font-size: 13px; text-align: center; margin-top: 12px; display: none; }
.links { text-align: center; margin-top: 20px; }
.links a { color: #58a6ff; text-decoration: none; font-size: 13px; }
.links a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="login-box">
<h1>🔐 管理后台</h1>
<div class="sub">SDF1 插件管理系统</div>
<?php echo function_exists('dfGuardAdminBanner') ? dfGuardAdminBanner() : ''; ?>
<div class="field">
<label>管理密码</label>
<input type="password" id="password" placeholder="请输入管理密码" autofocus>
</div>
<?php echo cgWidgetHtml(); ?>
<button class="login-btn" id="loginBtn" onclick="doLogin()">登 录</button>
<div class="error" id="errorMsg"></div>
<div class="links"><a href="player.php">← 返回玩家商城</a></div>
</div>
<script>
document.getElementById('password').addEventListener('keydown', e => {
if (e.key === 'Enter') doLogin();
});
async function doLogin() {
const pw = document.getElementById('password').value;
if (!pw) return;
document.getElementById('loginBtn').disabled = true;
document.getElementById('loginBtn').textContent = '登录中...';
// ★ 人机验证(2026-10-02):CF 渲染完即有 token(基本无感);VA 没做会在这里拉起验证
let cap = {};
if (window.CaptchaGuard) {
try {
cap = await CaptchaGuard.payload();
} catch (e) {
document.getElementById('errorMsg').textContent = e.message || '请先完成人机验证';
document.getElementById('errorMsg').style.display = 'block';
document.getElementById('loginBtn').disabled = false;
document.getElementById('loginBtn').textContent = '登 录';
return;
}
}
try {
const res = await fetch('api/admin.php?action=login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(Object.assign({password: pw}, cap))
});
const data = await res.json();
if (data.success) {
// ★ 第三层:登录接口在密码正确后才下发入口令牌,用来拼 admin.php?token=
const tk = (data.data && data.data.token) ? ('?token=' + encodeURIComponent(data.data.token)) : '';
// ★ 第一层:启用二次验证时先进 6 位动态码验证页,通过后再进后台
if (data.data && data.data.need_2fa) {
window.location.href = 'admin_2fa.php';
} else {
window.location.href = 'admin.php' + tk;
}
} else {
document.getElementById('errorMsg').textContent = data.message;
document.getElementById('errorMsg').style.display = 'block';
}
} catch (e) {
document.getElementById('errorMsg').textContent = '网络错误';
document.getElementById('errorMsg').style.display = 'block';
}
document.getElementById('loginBtn').disabled = false;
document.getElementById('loginBtn').textContent = '登 录';
}
</script>
</body>
</html>
-1142
View File
File diff suppressed because it is too large Load Diff
-2499
View File
File diff suppressed because it is too large Load Diff
-318
View File
@@ -1,318 +0,0 @@
<?php
/**
* 余额查询+充值API
* GET ?action=query - 查询余额(需token)
* POST ?action=recharge - 充值债券(需token)
* GET ?action=transactions - 查看流水(需token)
*/
// 防止任何输出污染JSON响应
ob_start();
require_once __DIR__ . '/../core.php';
ob_end_clean();
$action = getParam('action', 'query');
$token = getParam('token');
try {
switch ($action) {
case 'query':
balanceQuery($token);
break;
case 'recharge':
balanceRecharge($token);
break;
case 'transactions':
balanceTransactions($token);
break;
default:
error('未知操作: ' . $action);
}
// ★ WAL checkpoint:释放WAL锁,减少database is locked概率
try { walCheckpoint(); } catch (\Throwable $ignored) {}
} catch (\Throwable $e) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => ['code' => 500, 'message' => 'Internal error: ' . $e->getMessage()]], JSON_UNESCAPED_UNICODE);
exit;
}
// ===== 查询余额 =====
function balanceQuery($token) {
$player = getParam('player');
$password = getParam('password');
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!$player) error('缺少player');
// ★ 安全验证:必须验证token且token对应的玩家必须与查询的玩家匹配
$tokenInfo = null;
$queriedPlayer = $player; // 最终查询的玩家名
// 1. 检查是否为管理后台session
if (session_status() === PHP_SESSION_NONE) session_start();
$isAdmin = isset($_SESSION['admin_auth']) && $_SESSION['admin_auth'];
if ($isAdmin) {
// 管理员:可以查任意玩家
$tokenInfo = ['player' => null, 'purpose' => 'admin'];
} elseif ($token) {
// 2. 验证token并获取token对应的玩家名
$tempTokenInfo = validateToken($token);
if ($tempTokenInfo) {
$tokenPlayer = $tempTokenInfo['player'];
$tokenPurpose = $tempTokenInfo['purpose'] ?? '';
if ($tokenPurpose === 'admin' || $tokenPurpose === 'all') {
// 管理员token:可以查任意玩家
$tokenInfo = $tempTokenInfo;
} elseif ($tokenPlayer === $player) {
// 普通token:只能查自己的余额
// 进一步验证weblogin token的访问权限
$accessResult = validateWebAccess($token, 'view', $password, $ipAddress);
if ($accessResult['ok']) {
$queriedPlayer = $accessResult['player'];
$tokenInfo = ['player' => $queriedPlayer, 'purpose' => 'weblogin'];
} elseif ($accessResult['mode'] === 'need_password') {
jsonResponse([
'success' => false,
'need_password' => true,
'player' => $accessResult['player'],
'message' => $accessResult['message']
], 401);
} else {
error($accessResult['message'], 401);
}
} else {
error('无权查询其他玩家余额', 403);
}
} else {
error('无效token,请先登录', 401);
}
} else {
error('查询余额需要有效token,请先登录', 401);
}
// 查询债券余额(从Web端的债券缓存表)
$db = getDB();
// 先检查是否有缓存表
$db->exec("CREATE TABLE IF NOT EXISTS bond_cache (
player_name TEXT PRIMARY KEY,
amount INTEGER DEFAULT 0,
updated_at INTEGER DEFAULT 0
)");
$stmt = $db->prepare("SELECT amount, updated_at FROM bond_cache WHERE player_name = :name");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
// 同时查询积分
$stmt2 = $db->prepare("SELECT points FROM users WHERE player_name = :name");
$stmt2->bindValue(':name', $player, SQLITE3_TEXT);
$result2 = $stmt2->execute();
$row2 = $result2->fetchArray(SQLITE3_ASSOC);
$bonds = $row ? $row['amount'] : 0;
$points = $row2 ? $row2['points'] : 0;
$bondsUpdated = $row ? $row['updated_at'] : 0;
// 计算数据新鲜度
$freshness = '';
if ($bondsUpdated > 0) {
$age = time() - $bondsUpdated;
if ($age < 300) $freshness = '刚刚同步';
elseif ($age < 3600) $freshness = floor($age / 60) . '分钟前同步';
elseif ($age < 86400) $freshness = floor($age / 3600) . '小时前同步';
else $freshness = floor($age / 86400) . '天前同步(数据可能过期)';
} else {
$freshness = '尚未同步';
}
success([
'player' => $player,
'bonds' => $bonds,
'points' => $points,
'bonds_updated' => $bondsUpdated,
'freshness' => $freshness
]);
}
// ===== 充值债券 =====
function balanceRecharge($token) {
$player = getParam('player');
$amount = (int)getParam('amount', 0);
$operator = getParam('operator', 'web_admin');
if (!$player) error('缺少player');
if ($amount <= 0) error('充值金额必须大于0');
// 需要token
$tokenInfo = null;
$isPreview = true;
if ($token) {
$tokenInfo = validateToken($token);
if ($tokenInfo) {
$isPreview = false;
// 权限检查:只有管理员token才能充值
if ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all') {
error('充值需要管理员权限');
}
validateAndUseToken($token);
}
}
$db = getDB();
// 确保缓存表存在
$db->exec("CREATE TABLE IF NOT EXISTS bond_cache (
player_name TEXT PRIMARY KEY,
amount INTEGER DEFAULT 0,
updated_at INTEGER DEFAULT 0
)");
// 查询当前余额
$stmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
$currentBalance = $row ? $row['amount'] : 0;
$newBalance = $currentBalance + $amount;
if ($isPreview) {
preview([
'player' => $player,
'amount' => $amount,
'balance_before' => $currentBalance,
'balance_after' => $newBalance,
'operator' => $operator
], '预览模式 - 充值不会生效');
}
// 执行充值
if ($row) {
$stmt = $db->prepare("UPDATE bond_cache SET amount = :amount, updated_at = :time WHERE player_name = :name");
$stmt->bindValue(':amount', $newBalance, SQLITE3_INTEGER);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$stmt->execute();
} else {
$stmt = $db->prepare("INSERT INTO bond_cache (player_name, amount, updated_at) VALUES (:name, :amount, :time)");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$stmt->bindValue(':amount', $newBalance, SQLITE3_INTEGER);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
}
// 记录流水
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, status, created_at) VALUES (:player, 'admin_recharge', :amount, :reason, 'pending', :time)");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
$stmt->bindValue(':reason', "管理员充值: {$operator}", SQLITE3_TEXT);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
success([
'player' => $player,
'amount' => $amount,
'balance_before' => $currentBalance,
'balance_after' => $newBalance
], '充值成功');
}
// ===== 查看流水 =====
function balanceTransactions($token) {
$player = getParam('player');
$limit = (int)getParam('limit', 50);
// 需要token
$tokenInfo = null;
if ($token) {
$tokenInfo = validateToken($token);
}
if (!$tokenInfo) {
error('查看流水需要有效token', 401);
}
$db = getDB();
$txs = [];
if ($player) {
// 查看指定玩家流水
if ($tokenInfo['player'] !== $player && $tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all') {
error('无权查看其他玩家流水');
}
// Web端交易
try {
$stmt = $db->prepare("SELECT *, 'web' as source FROM web_transactions WHERE player_name = :player ORDER BY created_at DESC LIMIT :limit");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
} catch (\Throwable $e) { /* ignore */ }
// 游戏内交易
try {
$stmt = $db->prepare("SELECT java_id as id, player_name, type, amount, operator, reason, '' as detail, tx_time as created_at, 'game' as source, balance_before, balance_after FROM game_transactions WHERE player_name = :player ORDER BY tx_time DESC LIMIT :limit");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
} catch (\Throwable $e) { /* ignore */ }
} else {
// 管理员查看全服流水
if ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all') {
error('查看全服流水需要管理员权限');
}
// Web端交易
try {
$stmt = $db->prepare("SELECT *, 'web' as source FROM web_transactions ORDER BY created_at DESC LIMIT :limit");
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
} catch (\Throwable $e) { /* ignore */ }
// 游戏内交易
try {
$stmt = $db->prepare("SELECT java_id as id, player_name, type, amount, operator, reason, '' as detail, tx_time as created_at, 'game' as source, balance_before, balance_after FROM game_transactions ORDER BY tx_time DESC LIMIT :limit");
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
} catch (\Throwable $e) { /* ignore */ }
}
// 按时间排序(倒序)
usort($txs, function($a, $b) {
return ($b['created_at'] ?? 0) - ($a['created_at'] ?? 0);
});
$txs = array_slice($txs, 0, (int)$limit);
success($txs);
}
// ===== Weblogin Token验证(用于API调用) =====
function validateWebloginTokenForAPI($webToken) {
$db = getDB();
try {
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE web_token = :token");
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if (!$row) return false;
$createdAt = (int)$row['created_at'];
$expireSeconds = (int)$row['expire_seconds'];
if (time() - $createdAt > $expireSeconds) return false;
return [
'player' => $row['player_name'],
'purpose' => 'weblogin',
'created_at' => $createdAt,
'expires_at' => $createdAt + $expireSeconds
];
} catch (Exception $e) {
return false;
}
}
-320
View File
@@ -1,320 +0,0 @@
<?php
/**
* 收银台 API(独立页面后端)
* 支持:收银员登录/登出/会话、玩家查询、订单列表、收银员管理(仅管理员)
*/
// ★ 清除所有缓冲 + 发送 JSON Content-Type
while (ob_get_level() > 0) { @ob_end_clean(); }
ob_start();
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('memory_limit', '256M');
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
register_shutdown_function(function() {
while (ob_get_level() > 0) {
@ob_end_flush();
}
});
if (!extension_loaded('sqlite3')) {
exit(json_encode(['success' => false, 'message' => 'Missing SQLite3'], JSON_UNESCAPED_UNICODE));
}
require_once __DIR__ . '/../core.php';
if (!function_exists('error') || !function_exists('success') || !function_exists('getParam')) {
exit(json_encode(['success' => false, 'message' => 'core.php load failed'], JSON_UNESCAPED_UNICODE));
}
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$action = getParam('action', 'status');
try {
switch ($action) {
case 'login': cashierDoLogin(); break;
case 'logout': cashierDoLogout(); break;
case 'session': cashierSession(); break;
case 'player_check': cashierPlayerCheck(); break;
case 'order_list': cashierOrderList(); break;
case 'cashier_list': cashierList(); break;
case 'add': cashierAdd(); break;
case 'edit': cashierEdit(); break;
case 'delete': cashierDelete(); break;
default:
exit(json_encode(['success' => false, 'message' => '未知操作: ' . $action], JSON_UNESCAPED_UNICODE));
}
} catch (\Throwable $e) {
// ★ 2026-07-07 补强:顶层统一捕获 Throwable(含 Error),避免致命错误逃逸导致前端收到
// 非 JSON 响应(表现为“加载失败”却无具体原因)。同时落盘 debug.log 便于事后定位。
debugLog('[cashier.php] 顶层未捕获异常: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
exit(json_encode(['success' => false, 'message' => '服务器错误: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE));
}
// ===== 收银员登录 =====
function cashierDoLogin() {
$username = trim(getParam('username', ''));
$password = getParam('password', '');
if ($username === '' || $password === '') {
exit(json_encode(['success' => false, 'message' => '请输入账号和密码'], JSON_UNESCAPED_UNICODE));
}
if (cashierLogin($username, $password)) {
$c = getCurrentCashier();
exit(json_encode([
'success' => true,
'data' => [
'username' => $c['username'],
'discount_limit_percent' => $c['discount_limit_percent'],
'can_cash' => (int)($c['can_cash'] ?? 0)
]
], JSON_UNESCAPED_UNICODE));
}
exit(json_encode(['success' => false, 'message' => '账号或密码错误'], JSON_UNESCAPED_UNICODE));
}
// ===== 收银员登出 =====
function cashierDoLogout() {
cashierLogout();
exit(json_encode(['success' => true, 'message' => '已退出登录'], JSON_UNESCAPED_UNICODE));
}
// ===== 当前会话信息 =====
function cashierSession() {
if (isAdminLoggedIn()) {
exit(json_encode([
'success' => true,
'data' => [
'role' => 'admin',
'username' => 'admin',
'discount_limit_percent' => 100
]
], JSON_UNESCAPED_UNICODE));
}
if (isCashierLoggedIn()) {
$c = getCurrentCashier();
exit(json_encode([
'success' => true,
'data' => [
'role' => 'cashier',
'username' => $c['username'],
'discount_limit_percent' => $c['discount_limit_percent'],
'can_cash' => (int)($c['can_cash'] ?? 0)
]
], JSON_UNESCAPED_UNICODE));
}
exit(json_encode(['success' => false, 'message' => '未登录'], JSON_UNESCAPED_UNICODE));
}
// ===== 查询目标玩家余额/在线状态(收银员或管理员均可)=====
function cashierPlayerCheck() {
requireCashierOrAdminSession();
$player = trim(getParam('player'));
if (!$player) exit(json_encode(['success' => false, 'message' => '请输入玩家名'], JSON_UNESCAPED_UNICODE));
if (!preg_match('/^[a-zA-Z0-9_]{3,16}$/', $player)) {
exit(json_encode(['success' => false, 'message' => '玩家名格式不正确(3-16位英文/数字/下划线)'], JSON_UNESCAPED_UNICODE));
}
$db = getDB();
try {
$db->exec("INSERT OR IGNORE INTO bond_cache (player_name, amount, updated_at) VALUES ('" . str_replace("'", "''", $player) . "', 0, " . time() . ")");
$stmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
$balance = $row ? (int)$row['amount'] : 0;
$online = false;
try {
$ostmt = $db->prepare("SELECT player_name FROM online_players WHERE player_name = :name");
$ostmt->bindValue(':name', $player, SQLITE3_TEXT);
$orow = $ostmt->execute()->fetchArray(SQLITE3_ASSOC);
$online = $orow ? true : false;
} catch (\Throwable $e) {}
exit(json_encode([
'success' => true,
'data' => ['player' => $player, 'exists' => true, 'balance' => $balance, 'online' => $online]
], JSON_UNESCAPED_UNICODE));
} catch (Exception $e) {
@error_log('[cashier.php] Player check error: ' . $e->getMessage());
exit(json_encode(['success' => false, 'message' => '查询失败: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE));
}
}
// ===== 订单列表 =====
function cashierOrderList() {
$role = requireCashierOrAdminSession();
// 收银员只能看自己的订单
$c = null;
if ($role === 'cashier') {
$c = getCurrentCashier();
}
// ★ 尽早释放会话锁:查询期间不再占用 session 文件,避免阻塞同会话并发请求
if (session_status() === PHP_SESSION_ACTIVE) session_write_close();
// ★ 直接从独立 orders.db 读取:该库 Java 永不读写,彻底脱离 web.db 文件锁竞争,
// 因此不再需要 busy_timeout=15000 与 5 次重试兜底 hack —— 干净稳定的单次查询。
try {
$db = getOrdersDB();
} catch (\Throwable $e) {
exit(json_encode(['success' => false, 'message' => '订单库不可用: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE));
}
$limit = (int)getParam('limit', 100);
if ($limit <= 0 || $limit > 500) $limit = 100;
$sql = "SELECT * FROM cashier_orders";
$params = [];
if ($role === 'cashier' && $c) {
$sql .= " WHERE operator_name = :op";
$params[':op'] = $c['username'];
}
$sql .= " ORDER BY created_at DESC LIMIT " . (int)$limit;
$rows = [];
try {
$stmt = $db->prepare($sql);
if (!$stmt) {
exit(json_encode(['success' => false, 'message' => '查询失败: ' . $db->lastErrorMsg()], JSON_UNESCAPED_UNICODE));
}
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v, SQLITE3_TEXT);
}
$result = $stmt->execute();
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
if (!empty($r['items_detail'])) {
$r['items_detail'] = json_decode($r['items_detail'], true) ?: [];
}
$rows[] = $r;
}
} catch (\Throwable $e) {
// ★ 2026-07-07 加固:任何查询异常都返回合法 JSON,绝不把 PHP 报错文本注入响应
// (否则前端 r.json() 失败,订单页表现为“加载失败/报错”)。
debugLog('[cashier.php] order_list 查询异常: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
debugLog('[cashier.php] order_list 异常堆栈: ' . $e->getTraceAsString());
exit(json_encode([
'success' => false,
'message' => '订单查询异常: ' . $e->getMessage()
], JSON_UNESCAPED_UNICODE));
}
exit(json_encode(['success' => true, 'data' => $rows], JSON_UNESCAPED_UNICODE));
}
// ===== 收银员列表(仅管理员)=====
function cashierList() {
requireAdminSession();
$db = getDB();
$result = $db->prepare("SELECT id, username, discount_limit_percent, can_cash, created_at, created_by FROM cashiers ORDER BY id ASC")->execute();
$rows = [];
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
$rows[] = $r;
}
exit(json_encode(['success' => true, 'data' => $rows], JSON_UNESCAPED_UNICODE));
}
// ===== 新增收银员(仅管理员)=====
function cashierAdd() {
requireAdminSession();
$username = trim(getParam('username', ''));
$password = getParam('password', '');
$discountLimit = (int)getParam('discount_limit_percent', 0);
$canCash = (int)getParam('can_cash', 0);
if ($username === '' || $password === '') {
exit(json_encode(['success' => false, 'message' => '账号和密码不能为空'], JSON_UNESCAPED_UNICODE));
}
if (!preg_match('/^[a-zA-Z0-9_]{3,20}$/', $username)) {
exit(json_encode(['success' => false, 'message' => '账号格式不正确(3-20位英文/数字/下划线)'], JSON_UNESCAPED_UNICODE));
}
if ($discountLimit < 0 || $discountLimit > 100) {
exit(json_encode(['success' => false, 'message' => '折扣上限需在0-100之间'], JSON_UNESCAPED_UNICODE));
}
if ($canCash < 0 || $canCash > 1) {
exit(json_encode(['success' => false, 'message' => '现金收款权限需为0或1'], JSON_UNESCAPED_UNICODE));
}
$db = getDB();
// 查重
$chk = $db->prepare("SELECT id FROM cashiers WHERE username = :u");
$chk->bindValue(':u', $username, SQLITE3_TEXT);
if ($chk->execute()->fetchArray(SQLITE3_ASSOC)) {
exit(json_encode(['success' => false, 'message' => '账号已存在'], JSON_UNESCAPED_UNICODE));
}
$hash = password_hash($password, PASSWORD_DEFAULT);
$salt = bin2hex(random_bytes(8));
$stmt = $db->prepare("INSERT INTO cashiers (username, password_hash, salt, discount_limit_percent, can_cash, created_at, created_by) VALUES (:u,:h,:s,:d,:c,:t,'admin')");
$stmt->bindValue(':u', $username, SQLITE3_TEXT);
$stmt->bindValue(':h', $hash, SQLITE3_TEXT);
$stmt->bindValue(':s', $salt, SQLITE3_TEXT);
$stmt->bindValue(':d', $discountLimit, SQLITE3_INTEGER);
$stmt->bindValue(':c', $canCash, SQLITE3_INTEGER);
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
$stmt->execute();
exit(json_encode(['success' => true, 'message' => '收银员创建成功'], JSON_UNESCAPED_UNICODE));
}
// ===== 编辑收银员(仅管理员):可改密码 / 折扣上限 =====
function cashierEdit() {
requireAdminSession();
$id = (int)getParam('id', 0);
if ($id <= 0) exit(json_encode(['success' => false, 'message' => '缺少id'], JSON_UNESCAPED_UNICODE));
$db = getDB();
$chk = $db->prepare("SELECT id FROM cashiers WHERE id = :id");
$chk->bindValue(':id', $id, SQLITE3_INTEGER);
if (!$chk->execute()->fetchArray(SQLITE3_ASSOC)) {
exit(json_encode(['success' => false, 'message' => '收银员不存在'], JSON_UNESCAPED_UNICODE));
}
$sets = [];
$params = [':id' => $id];
$newPassword = getParam('password', '');
if ($newPassword !== '') {
$sets[] = "password_hash = :h";
$params[':h'] = password_hash($newPassword, PASSWORD_DEFAULT);
$sets[] = "salt = :s";
$params[':s'] = bin2hex(random_bytes(8));
}
if (getParam('discount_limit_percent') !== null && getParam('discount_limit_percent') !== '') {
$dl = (int)getParam('discount_limit_percent', 0);
if ($dl < 0 || $dl > 100) {
exit(json_encode(['success' => false, 'message' => '折扣上限需在0-100之间'], JSON_UNESCAPED_UNICODE));
}
$sets[] = "discount_limit_percent = :d";
$params[':d'] = $dl;
}
if (getParam('can_cash') !== null && getParam('can_cash') !== '') {
$cc = (int)getParam('can_cash', 0);
if ($cc < 0 || $cc > 1) {
exit(json_encode(['success' => false, 'message' => '现金收款权限需为0或1'], JSON_UNESCAPED_UNICODE));
}
$sets[] = "can_cash = :c";
$params[':c'] = $cc;
}
if (empty($sets)) {
exit(json_encode(['success' => false, 'message' => '没有可更新的字段'], JSON_UNESCAPED_UNICODE));
}
$sql = "UPDATE cashiers SET " . implode(', ', $sets) . " WHERE id = :id";
$stmt = $db->prepare($sql);
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v, is_int($v) ? SQLITE3_INTEGER : SQLITE3_TEXT);
}
$stmt->execute();
exit(json_encode(['success' => true, 'message' => '收银员信息已更新'], JSON_UNESCAPED_UNICODE));
}
// ===== 删除收银员(仅管理员)=====
function cashierDelete() {
requireAdminSession();
$id = (int)getParam('id', 0);
if ($id <= 0) exit(json_encode(['success' => false, 'message' => '缺少id'], JSON_UNESCAPED_UNICODE));
$db = getDB();
$stmt = $db->prepare("DELETE FROM cashiers WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
exit(json_encode(['success' => true, 'message' => '收银员已删除'], JSON_UNESCAPED_UNICODE));
}
-491
View File
@@ -1,491 +0,0 @@
<?php
/**
* CDK兑换API v2 - 20260620
* POST ?action=exchange - CDK兑换(需token,由插件请求)
* POST ?action=create - 创建CDK(需管理token)
* GET ?action=list - CDK列表(需管理token)
* POST ?action=delete - 删除CDK(需管理token)
* POST ?action=batch - 批量生成CDK(需管理token)
*/
// 防止任何输出污染JSON响应
while (ob_get_level() > 0) { ob_end_clean(); }
require_once __DIR__ . '/../core.php';
// 再次确保清理前置输出
while (ob_get_level() > 0) { ob_end_clean(); }
$action = getParam('action', 'exchange');
$token = getParam('token');
try {
switch ($action) {
case 'exchange':
cdkExchange($token);
break;
case 'create':
cdkCreate($token);
break;
case 'list':
cdkList($token);
break;
case 'delete':
cdkDelete($token);
break;
case 'batch':
cdkBatch($token);
break;
default:
error('未知操作: ' . $action);
}
// ★ WAL checkpoint:释放WAL锁,减少database is locked概率
try { walCheckpoint(); } catch (\Throwable $ignored) {}
} catch (\Throwable $e) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => ['code' => 500, 'message' => 'Internal error: ' . $e->getMessage()]], JSON_UNESCAPED_UNICODE);
exit;
}
// ===== CDK兑换 =====
function cdkExchange($token) {
$code = getParam('code');
$player = getParam('player');
$password = getParam('password');
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!$code) error('缺少code');
if (!$player) error('缺少player');
// ★ 双保险验证:CDK兑换需要密码确认
$isPreview = true;
$validated = false;
if ($token) {
// 先检查是否为普通管理token
$tokenInfo = validateToken($token);
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
$isPreview = false;
$validated = true;
} else {
// weblogin token → 走双保险验证
$accessResult = validateWebAccess($token, 'cdk', $password, $ipAddress);
if ($accessResult['ok'] || $accessResult['mode'] === 'full_verified') {
$isPreview = false;
$validated = true;
$player = $accessResult['player'];
} elseif ($accessResult['mode'] === 'need_password') {
jsonResponse([
'success' => false,
'need_password' => true,
'player' => $accessResult['player'],
'message' => $accessResult['message']
], 401);
} else {
error($accessResult['message'], 401);
}
}
}
// 如果没有token也不是管理token,返回预览
if (!$validated) {
$isPreview = true;
}
$db = getDB();
// ★ 第32批拉黑拦截:本地兑换前先查拉黑名单
// (web_player_blacklist 由 Java BlacklistManager 经 sync_blacklist 同步;
// 本地CDK存在也一律拒绝,与 Java CDKManager.redeem 的检查口径一致)
$blackReason = playerWebBlacklistReason($player);
if ($blackReason !== null) {
@error_log("[cdk] 拉黑拦截兑换: player={$player}");
error('你已被拉黑:' . $blackReason);
}
// 查询CDK
$stmt = $db->prepare("SELECT * FROM cdk WHERE code = :code");
$stmt->bindValue(':code', strtoupper(trim($code)), SQLITE3_TEXT);
$result = $stmt->execute();
$cdk = $result->fetchArray(SQLITE3_ASSOC);
if (!$cdk) {
// ★ 本地没找到 → 写待验证请求,轮询Java Sdf1_login返回结果
$cdk = tryRemoteCdkValidation($db, $code, $player);
if (!$cdk) {
error('CDK不存在: ' . $code);
}
}
if ($cdk['used'] == 1) {
error('CDK已被使用: ' . $cdk['used_by']);
}
// 查询当前余额
$db->exec("CREATE TABLE IF NOT EXISTS bond_cache (
player_name TEXT PRIMARY KEY,
amount INTEGER DEFAULT 0,
updated_at INTEGER DEFAULT 0
)");
$stmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
$currentBalance = $row ? $row['amount'] : 0;
$newBalance = $currentBalance + $cdk['amount'];
if ($isPreview) {
preview([
'code' => $code,
'amount' => $cdk['amount'],
'type' => $cdk['type'],
'player' => $player,
'balance_before' => $currentBalance,
'balance_after' => $newBalance,
'valid' => true
], '预览模式 - CDK兑换不会生效');
}
// 执行兑换
$now = time();
$isRemote = !empty($cdk['_remote']);
// ★ 全部DB操作包装在事务中,防止database is locked
$db->exec('BEGIN IMMEDIATE');
try {
// 标记CDK已使用(仅本地CDK需要)
if (!$isRemote) {
$stmt = $db->prepare("UPDATE cdk SET used = 1, used_by = :player, used_at = :time WHERE code = :code");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
$stmt->bindValue(':code', strtoupper(trim($code)), SQLITE3_TEXT);
$stmt->execute();
}
// ★ PHP只负责记录交易,不更新bond_cache(Java端负责加债券)
// 记录流水:远程CDK标记completed(Java在pullWebCdkRequestsAndValidate中已直接加债券)
// 本地CDK标记pending(Java通过pullPendingTransactions拉取后处理)
$txStatus = $isRemote ? 'completed' : 'pending';
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'cdk_redeem', :amount, :reason, :detail, :status, :time)");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':amount', $cdk['amount'], SQLITE3_INTEGER);
$stmt->bindValue(':reason', $isRemote ? "CDK远程兑换: {$code}" : "CDK兑换: {$code}", SQLITE3_TEXT);
$stmt->bindValue(':detail', json_encode(['code' => $code, 'remote' => $isRemote], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
$stmt->bindValue(':status', $txStatus, SQLITE3_TEXT);
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
$stmt->execute();
// 只有本地CDK需要sync_requests触发Java立即同步
if (!$isRemote) {
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
$stmt2 = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
$stmt2->bindValue(':player', $player, SQLITE3_TEXT);
$stmt2->bindValue(':time', $now, SQLITE3_INTEGER);
$stmt2->execute();
}
$db->exec('COMMIT');
} catch (\Throwable $e) {
try { $db->exec('ROLLBACK'); } catch (\Throwable $e2) {}
@error_log("[cdk] 兑换事务失败: " . $e->getMessage());
}
success([
'code' => $code,
'amount' => $cdk['amount'],
'player' => $player,
'balance_before' => $currentBalance,
'balance_after' => $currentBalance
], 'CDK兑换成功,债券将由Java端处理');
}
// ===== 创建CDK =====
function cdkCreate($token) {
$amount = (int)getParam('amount', 0);
$type = getParam('type', 'bond');
$code = getParam('code');
if ($amount <= 0) error('金额必须大于0');
// 需要管理token
$tokenInfo = null;
$isPreview = true;
if ($token) {
$tokenInfo = validateToken($token);
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
$isPreview = false;
validateAndUseToken($token);
}
}
if (!$code) {
$code = strtoupper(bin2hex(random_bytes(4))); // 8位随机码
}
$db = getDB();
$now = time();
// 检查是否已存在
$stmt = $db->prepare("SELECT 1 FROM cdk WHERE code = :code");
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
$result = $stmt->execute();
if ($result->fetchArray()) {
error('CDK已存在: ' . $code);
}
if ($isPreview) {
preview([
'code' => $code,
'amount' => $amount,
'type' => $type
], '预览模式 - CDK创建不会生效');
}
$stmt = $db->prepare("INSERT INTO cdk (code, amount, type, created_at) VALUES (:code, :amount, :type, :time)");
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
$stmt->execute();
success(['code' => $code, 'amount' => $amount, 'type' => $type], 'CDK创建成功');
}
// ===== CDK列表 =====
function cdkList($token) {
$tokenInfo = null;
$isPreview = true;
if ($token) {
$tokenInfo = validateToken($token);
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
$isPreview = false;
}
}
$db = getDB();
$result = $db->query("SELECT code, amount, type, used, used_by, created_at, used_at FROM cdk ORDER BY created_at DESC LIMIT 200");
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
if ($isPreview) {
preview($list, '预览模式');
} else {
success($list);
}
}
// ===== 删除CDK =====
function cdkDelete($token) {
$code = getParam('code');
if (!$code) error('缺少code');
$tokenInfo = null;
$isPreview = true;
if ($token) {
$tokenInfo = validateToken($token);
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
$isPreview = false;
validateAndUseToken($token);
}
}
$db = getDB();
if ($isPreview) {
preview(['code' => $code], '预览模式 - 删除不会生效');
}
$stmt = $db->prepare("DELETE FROM cdk WHERE code = :code");
$stmt->bindValue(':code', strtoupper(trim($code)), SQLITE3_TEXT);
$stmt->execute();
success(['code' => $code], 'CDK已删除');
}
// ===== 批量生成CDK =====
function cdkBatch($token) {
$amount = (int)getParam('amount', 0);
$count = (int)getParam('count', 1);
$type = getParam('type', 'bond');
if ($amount <= 0) error('金额必须大于0');
if ($count < 1 || $count > 100) error('数量范围: 1-100');
$tokenInfo = null;
$isPreview = true;
if ($token) {
$tokenInfo = validateToken($token);
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
$isPreview = false;
validateAndUseToken($token);
}
}
$db = getDB();
$now = time();
$codes = [];
for ($i = 0; $i < $count; $i++) {
$code = strtoupper(bin2hex(random_bytes(4)));
$codes[] = $code;
if (!$isPreview) {
$stmt = $db->prepare("INSERT INTO cdk (code, amount, type, created_at) VALUES (:code, :amount, :type, :time)");
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
$stmt->execute();
}
}
if ($isPreview) {
preview([
'codes' => $codes,
'amount' => $amount,
'count' => $count
], '预览模式 - 批量创建不会生效');
} else {
success([
'codes' => $codes,
'amount' => $amount,
'count' => $count
], "成功创建{$count}个CDK");
}
}
// ===== Weblogin Token验证(用于CDK兑换)=====
function validateWebloginTokenForCDK($webToken) {
$db = getDB();
try {
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE web_token = :token");
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if (!$row) return false;
$createdAt = (int)$row['created_at'];
$expireSeconds = (int)$row['expire_seconds'];
if (time() - $createdAt > $expireSeconds) return false;
return [
'player' => $row['player_name'],
'purpose' => 'weblogin',
'created_at' => $createdAt,
'expires_at' => $createdAt + $expireSeconds
];
} catch (\Throwable $e) {
return false;
}
}
// ===== 远程CDK验证:写请求表,轮询Java Sdf1_login结果 =====
function tryRemoteCdkValidation($db, $code, $player = '') {
$db->exec("CREATE TABLE IF NOT EXISTS cdk_validate_requests (
request_id TEXT PRIMARY KEY,
code TEXT NOT NULL,
player_name TEXT DEFAULT '',
status TEXT DEFAULT 'pending',
created_at INTEGER NOT NULL
)");
// ★ 迁移:给已有表补 player_name 列
$cols = [];
$colResult = $db->query("PRAGMA table_info(cdk_validate_requests)");
while ($col = $colResult->fetchArray(SQLITE3_ASSOC)) { $cols[] = $col['name']; }
if (!in_array('player_name', $cols)) {
$db->exec("ALTER TABLE cdk_validate_requests ADD COLUMN player_name TEXT DEFAULT ''");
}
$db->exec("CREATE TABLE IF NOT EXISTS cdk_validate_results (
request_id TEXT PRIMARY KEY,
code TEXT NOT NULL,
status TEXT NOT NULL,
amount INTEGER DEFAULT 0,
message TEXT DEFAULT '',
used INTEGER DEFAULT 0,
used_by TEXT DEFAULT '',
created_at INTEGER NOT NULL
)");
$requestId = strtoupper(bin2hex(random_bytes(8)));
$now = time();
// ★ 写入待验证请求 — 用BEGIN IMMEDIATE防止database is locked
try {
$db->exec('BEGIN IMMEDIATE');
$stmt = $db->prepare("INSERT OR REPLACE INTO cdk_validate_requests (request_id, code, player_name, status, created_at) VALUES (:id, :code, :player, 'pending', :time)");
$stmt->bindValue(':id', $requestId, SQLITE3_TEXT);
$stmt->bindValue(':code', strtoupper(trim($code)), SQLITE3_TEXT);
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (\Throwable $e) {
$db->exec('ROLLBACK');
@error_log("[cdk] 远程验证写入失败: {$e->getMessage()}");
return null;
}
@error_log("[cdk] 远程验证请求: {$requestId} code={$code} player={$player}");
// ★ 轮询结果(最多8秒,每300ms一次)
$result = null;
for ($i = 0; $i < 27; $i++) {
usleep(300000); // 300ms
$stmt = $db->prepare("SELECT * FROM cdk_validate_results WHERE request_id = :id");
$stmt->bindValue(':id', $requestId, SQLITE3_TEXT);
$rs = $stmt->execute();
$result = $rs->fetchArray(SQLITE3_ASSOC);
if ($result) break;
}
// 清理请求和结果
try {
$db->exec('BEGIN IMMEDIATE');
$db->exec("DELETE FROM cdk_validate_requests WHERE request_id = '" . SQLite3::escapeString($requestId) . "'");
if ($result) {
$db->exec("DELETE FROM cdk_validate_results WHERE request_id = '" . SQLite3::escapeString($requestId) . "'");
}
$db->exec('COMMIT');
} catch (\Throwable $e) {
$db->exec('ROLLBACK');
}
if (!$result) {
@error_log("[cdk] 远程验证超时: {$requestId}");
return null;
}
@error_log("[cdk] 远程验证结果: status={$result['status']} amount={$result['amount']}");
// ★ 第32批:Java端拉黑拦截信号 → 直接提示被拉黑
// (若当 null 返回,上层会误报「CDK不存在」,掩盖真实原因)
if ($result['status'] === 'blacklisted') {
$blReason = playerWebBlacklistReason($player);
@error_log("[cdk] 远程验证 blacklisted: player={$player}");
error('你已被拉黑:' . ($blReason !== null ? $blReason : '违反服务器规则'));
}
if ($result['status'] === 'success') {
// ★ Java端已标记used并加债券,Web端只做记录不重复加钱
return [
'code' => $result['code'],
'amount' => (int)$result['amount'],
'type' => 'bond',
'used' => 0,
'used_by' => '',
'created_at' => $now,
'used_at' => 0,
'_remote' => true
];
}
return null;
}
-30
View File
@@ -1,30 +0,0 @@
<?php
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
require_once __DIR__ . '/../security.php'; // 第四层:全局限流(2026-09-30)
require_once __DIR__ . '/../inc/function.php';
/** @var PDO $db */
function getPlayerList() {
global $db;
$stmt = $db->prepare("SELECT player_name FROM users");
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$players = [];
foreach ($rows as $row) {
$players[] = $row['player_name'];
}
exit(json_encode(['success' => true, 'data' => $players], JSON_UNESCAPED_UNICODE));
}
if (!isLogin()) error('请先登录', 401);
if ($_SERVER['REQUEST_METHOD'] !== 'GET') error('方法不允许');
getPlayerList();
-3770
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-1290
View File
File diff suppressed because it is too large Load Diff
-346
View File
@@ -1,346 +0,0 @@
<?php
/**
* 收银台订单前缀 / 订单号生成 —— 统一读配置的公共小工具(2026-10-06 创建,2026-10-07 改读 pay.md)
*
* 背景:平台 MySQL 的 pay_order 是多商户共用表,创单与三个补单脚本此前都把前缀
* 写死在各自代码里。生产服 / 测试服共用一个前缀就会互相捡单 —— 测试服
* 补单器把生产服的已支付单补进测试库,反过来也一样(2026-10-06 串数据的根因)。
*
* ★ 2026-10-07 订单号规则改读站点根目录 pay.md(配置+文档二合一文件):
* - 只认「### 以下是配置区域:」后第一个 ```json``` 块,其余说明区全部忽略;
* - 「前缀码」= 完整订单前缀(创单与补单永远一致),payOrderPrefix() 的第一来源;
* - 「是否固定订单号格式」= true → 用「订单号格式」模板:[]去掉、纯数字的 0 换随机;
* 模板非纯数字(或为空)→ 回退保底 6 位递增序列 000001/000002…(计数器持久化在
* db/pay_order_seq.txt,flock 防并发撞单);
* - = false → 按「当前配置」组合规则生成(+/- 连接符不进订单号,验证码位数读
* 「订单验证码长度」字段而非文本里的数字);
* - pay.md 缺失 / 解析失败 → 一律回退旧版逻辑(前缀+时间+4码+玩家名),行为与改造前一致。
*
* 前缀兼容(防串单,2026-10-06 事故修复的延续):
* - payOrderPrefix() 第一来源 pay.md「前缀码」→ 回退 config.php 的 PAY_ORDER_PREFIX
* → 回退 'RE',只保留字母数字(要拼进 SQL LIKE,与平台侧约定一致);
* - payOrderIsOurs() 认「当前前缀 + 历史前缀(PAY_ORDER_PREFIX)」任一,改前缀后
* 历史在途单仍被补单器/查询接口认领;
* - payOrderPrefixLikeAny() 给补单器用的双前缀 SQL 条件(poller_online /
* recharge_orders_api / pay_poller),扫描范围与 payOrderIsOurs 一致。
*
* ★ 本文件不依赖 core.php,任何脚本(含自包含的 poller)都能直接 require。
* ★ 可用 define('PAY_MD_PATH', '/path/pay.md') 覆盖 pay.md 路径(仅测试用)。
* ★ 债券换算(estimateBonds)不在本文件:金额→债券由后台「充值商店配置」
* (shop_configs.bond_reward) 决定,代码不干预比例。
*/
if (!function_exists('payMdOrderConfig')) {
/**
* 读 pay.md「### 以下是配置区域:」之后的第一个 ```json``` 块。
* 二合一文件里说明区的表格 / 文本一律忽略。
*
* @return array|null 解析成功返回关联数组;缺失/损坏返回 null(调用方回退旧逻辑)
*/
function payMdOrderConfig() {
$path = defined('PAY_MD_PATH') ? PAY_MD_PATH : dirname(__DIR__) . '/pay.md';
if (!is_file($path)) return null;
$md = @file_get_contents($path);
if (!is_string($md) || $md === '') return null;
$pos = strpos($md, '以下是配置区域');
if ($pos === false) return null;
if (!preg_match('/```(?:json|JSON)?\s*(.*?)```/s', substr($md, $pos), $m)) return null;
$cfg = json_decode(trim($m[1]), true);
return is_array($cfg) ? $cfg : null;
}
}
if (!function_exists('payOrderPrefix')) {
/**
* 订单号前缀(永不返回空串)—— 第一来源 pay.md「前缀码」。
* pay.md 缺失/未配/解析失败时回退 config.php 的 PAY_ORDER_PREFIX,再回退 'RE'
* (与 2026-10-06 改造前的行为完全一致,线上老配置零变化)。
* 只保留字母数字:它要拼进 SQL 的 LIKE '前缀%',也与平台侧约定一致。
*/
function payOrderPrefix() {
$p = '';
$cfg = payMdOrderConfig();
if (is_array($cfg) && isset($cfg['前缀码'])) {
$p = trim((string)$cfg['前缀码']);
}
if ($p === '') {
$p = defined('PAY_ORDER_PREFIX') ? trim((string)PAY_ORDER_PREFIX) : '';
}
if ($p === '') return 'RE';
$p = preg_replace('/[^A-Za-z0-9]/', '', $p);
return $p === '' ? 'RE' : $p;
}
}
if (!function_exists('payOrderPrefixes')) {
/**
* 本端全部有效前缀 = [当前前缀(pay.md), 历史前缀(PAY_ORDER_PREFIX,不同时才带上)]。
* 用于「认旧单」:改前缀后,改动前已生成、尚未补单的订单仍能被本端认领。
* @return string[]
*/
function payOrderPrefixes() {
$list = array(payOrderPrefix());
$legacy = defined('PAY_ORDER_PREFIX') ? preg_replace('/[^A-Za-z0-9]/', '', trim((string)PAY_ORDER_PREFIX)) : '';
if ($legacy !== '' && !in_array($legacy, $list, true)) {
$list[] = $legacy;
}
return $list;
}
}
if (!function_exists('payOrderPrefixLen')) {
/** 前缀长度(strncmp 用) */
function payOrderPrefixLen() {
return strlen(payOrderPrefix());
}
}
if (!function_exists('payOrderIsOurs')) {
/**
* 判断一个 out_trade_no 是否本端订单(补单器/查询接口的兜底校验)
* 当前前缀与历史前缀命中其一即算本端单。
* @param string $outNo
* @return bool
*/
function payOrderIsOurs($outNo) {
foreach (payOrderPrefixes() as $p) {
if (strncmp((string)$outNo, $p, strlen($p)) === 0) return true;
}
return false;
}
}
if (!function_exists('payOrderPrefixLike')) {
/** SQL 片段:out_trade_no LIKE '当前前缀%'(单前缀,向后兼容保留) */
function payOrderPrefixLike() {
return payOrderPrefix() . '%';
}
}
if (!function_exists('payOrderPrefixLikeAny')) {
/**
* SQL 条件片段:out_trade_no LIKE '当前前缀%' OR out_trade_no LIKE '历史前缀%'。
* 补单器扫描用,与 payOrderIsOurs 的认定范围一致;前缀均已净化为字母数字,无需转义。
* 用法:WHERE (".payOrderPrefixLikeAny().") AND status IN (1,2)
* @return string
*/
function payOrderPrefixLikeAny() {
$conds = array();
foreach (payOrderPrefixes() as $p) {
$conds[] = "out_trade_no LIKE '" . $p . "%'";
}
return implode(' OR ', $conds);
}
}
if (!function_exists('payRandomCode')) {
/**
* 随机校验码:长度 1~32,去掉易混的 I/O/0/1
* @param int $len
* @return string
*/
function payRandomCode($len) {
$len = (int)$len;
if ($len < 1) $len = 4;
if ($len > 32) $len = 32;
$cs = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$out = '';
for ($i = 0; $i < $len; $i++) {
$out .= $cs[mt_rand(0, strlen($cs) - 1)];
}
return $out;
}
}
if (!function_exists('payComposeOrderNo')) {
/**
* 按「当前配置」组合规则拼订单号。连接符(+/-/+/-)只作分隔,不进订单号。
* 变量映射(中文变量名与说明区英文别名都认):
* 剦缀码/front → 当前前缀;当前时间/下单时间/timeis → YmdHis;
* 订单验证码/校验码/ordcode → 随机码(长度读「订单验证码长度」,不读文本里的"N位");
* 玩家名字/用户名/username → 净化后的玩家名;未知变量忽略。
*
* @param string $tpl 「当前配置」原文,如 剦缀码+当前时间+4位订单验证码+玩家名字
* @param int $codeLen 验证码长度
* @param string $name 已净化的玩家名
* @return array|null [前段, 后段, 是否含玩家名](订单号=前段.玩家名.后段);无有效段返回 null
*/
function payComposeOrderNo($tpl, $codeLen, $name) {
$tokens = preg_split('/[+\-\x{FF0B}\x{FF0D}]/u', (string)$tpl);
if (!is_array($tokens) || count($tokens) === 0) return null;
$pre = ''; // 玩家名之前的段落
$post = ''; // 玩家名之后的段落
$withName = false; // 组合里含玩家名变量(payGuardLen 据此裁剪)
$afterName = false; // 已越过玩家名位置
$valid = false;
foreach ($tokens as $t) {
$t = trim($t);
if ($t === '') continue;
$tl = strtolower($t);
if (strpos($t, '前缀') !== false || $tl === 'front') {
$seg = payOrderPrefix();
} elseif (strpos($t, '时间') !== false || strpos($t, '日期') !== false || $tl === 'timeis') {
$seg = date('YmdHis');
} elseif (strpos($t, '验证码') !== false || strpos($t, '校验码') !== false || $tl === 'ordcode') {
$seg = payRandomCode($codeLen);
} elseif (!$afterName && !$withName
&& (strpos($t, '玩家') !== false || strpos($t, '用户') !== false || $tl === 'username')) {
// 玩家名段:本身由 payGuardLen 统一拼装,这里只切换分段状态(重复的玩家名变量忽略)
$withName = true;
$afterName = true;
$valid = true;
continue;
} else {
continue; // 未知变量忽略
}
if ($afterName) $post .= $seg; else $pre .= $seg;
$valid = true;
}
if (!$valid) return null;
return array($pre, $post, $withName);
}
}
if (!function_exists('payMakeFixedOrderNo')) {
/**
* 固定订单号格式分支:前缀码 + 订单号格式模板。
* 模板 [] 去掉;
* - 纯数字(如 00000000)→ 按【模板位数】递增序列:00000001、00000002…
* (计数器持久化在 db/pay_order_seq.txt,flock 防撞单;当前无订单则从 1 开始);
* - 非纯数字或为空 → 保底 6 位递增序列 000001、000002…。
* @param array $cfg pay.md 配置区
* @return string
*/
function payMakeFixedOrderNo($cfg) {
$p = payOrderPrefix();
$tpl = isset($cfg['订单号格式']) ? (string)$cfg['订单号格式'] : '';
$tpl = str_replace(array('[', ']', "\u{FF3B}", "\u{FF3D}", ' ', "\t"), '', $tpl);
if ($tpl !== '' && ctype_digit($tpl)) {
// ★ 纯数字模板:按模板位数递增序列(00000000 → CYZJ00000001, CYZJ00000002…)
$no = payMakeSequentialOrderNo($tpl);
} else {
// 非纯数字模板(或空)→ 保底 6 位递增序列
$no = payMakeSequentialOrderNo('000000');
}
$max = defined('PAY_ORDER_MAX_LEN') ? (int)PAY_ORDER_MAX_LEN : 64;
if ($max > 0 && strlen($no) > $max) $no = substr($no, 0, $max);
return $no;
}
}
if (!function_exists('payMakeSequentialOrderNo')) {
/**
* 递增序列订单号:前缀 + 模板位数的序列(000001、000002…,默认 6 位)。
* 计数器持久化在 db/pay_order_seq.txt(flock 排他锁读写改,防并发撞单);
* 计数器不可写时退化为「前缀+日期时间+3位随机」,保底不与历史单撞号。
*
* @param string $tpl 位数模板(纯数字,如 '00000000'),决定序列补零位数
* @return string
*/
function payMakeSequentialOrderNo($tpl = '000000') {
$p = payOrderPrefix();
$width = strlen(ctype_digit($tpl) ? $tpl : '000000');
$dir = dirname(__DIR__) . '/db';
if (!is_dir($dir)) @mkdir($dir, 0755, true);
$file = $dir . '/pay_order_seq.txt';
$n = 0;
$fp = @fopen($file, 'c+');
if ($fp !== false) {
if (flock($fp, LOCK_EX)) {
$raw = trim((string)stream_get_contents($fp));
$n = (is_numeric($raw) && (int)$raw > 0) ? (int)$raw : 0;
$n++;
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, (string)$n);
fflush($fp);
flock($fp, LOCK_UN);
}
fclose($fp);
}
if ($n <= 0) {
return $p . date('ymdHis') . str_pad((string)mt_rand(0, 999), 3, '0', STR_PAD_LEFT);
}
return $p . str_pad((string)$n, $width, '0', STR_PAD_LEFT);
}
}
if (!function_exists('payGuardLen')) {
/**
* 长度护栏:平台 out_trade_no 默认 varchar(64)。
* 订单号 = $pre . 玩家名 . $post($withName=false 时玩家名不参与)。
* 超长时先裁玩家名段,其次整体裁尾(前缀在头部绝不被裁 —— 前缀一旦被截,
* 补单器就再也认不出这单了)。
*
* @return string
*/
function payGuardLen($pre, $post, $name, $withName) {
$max = defined('PAY_ORDER_MAX_LEN') ? (int)PAY_ORDER_MAX_LEN : 64;
if ($max <= 0) return $withName ? $pre . $name . $post : $pre . $post;
if (!$withName || $name === '') {
$no = $pre . $post;
return strlen($no) > $max ? substr($no, 0, $max) : $no;
}
$fixed = strlen($pre) + strlen($post);
$allow = $max - $fixed;
if ($allow >= strlen($name)) {
return $pre . $name . $post;
}
if ($allow > 0) {
return $pre . substr($name, 0, $allow) . $post;
}
// 极端情况(前缀配得极长):先退回不含玩家名的形态,仍超长则整体裁尾保前缀
$no = $pre . $post;
return strlen($no) > $max ? substr($no, 0, $max) : $no;
}
}
if (!function_exists('payMakeOrderNo')) {
/**
* 生成本端订单号(2026-10-07 改读 pay.md 配置区)
*
* 分支:
* 1) 「是否固定订单号格式」= true → payMakeFixedOrderNo(模板纯数字0换随机 /
* 非纯数字回退 6 位递增序列),不再读组合规则;
* 2) = false(或 pay.md 缺失/解析失败)→ 按「当前配置」组合;组合规则缺失时
* 回退旧版默认(前缀 + 年月日时分秒 + 4位随机校验码 + 玩家名,
* 例:CYZJ20261006171418Q101youpaishidifu),行为与改造前完全一致。
*
* 玩家名只保留 [A-Za-z0-9_](Minecraft 用户名规则),其余字符剔除;
* 剔除后为空则整段省略,保证前缀/时间戳/校验码绝不被破坏。
*
* @param string $playerName 下单玩家名
* @return string
*/
function payMakeOrderNo($playerName = '') {
$cfg = payMdOrderConfig();
// 玩家名净化:只留字母数字下划线
$name = preg_replace('/[^A-Za-z0-9_]/', '', (string)$playerName);
if ($name === '') $name = '';
// —— 分支 1:固定订单号格式 ——
if (is_array($cfg) && !empty($cfg['是否固定订单号格式'])) {
return payMakeFixedOrderNo($cfg);
}
// —— 分支 2:按「当前配置」组合规则 ——
if (is_array($cfg) && isset($cfg['当前配置']) && trim((string)$cfg['当前配置']) !== '') {
$codeLen = 4;
if (isset($cfg['订单验证码长度'])) {
$l = (int)$cfg['订单验证码长度'];
if ($l >= 1 && $l <= 32) $codeLen = $l;
}
$r = payComposeOrderNo(trim((string)$cfg['当前配置']), $codeLen, $name);
if ($r !== null && ($r[0] . $r[1]) !== '') {
return payGuardLen($r[0], $r[1], $name, $r[2]);
}
}
// —— 回退:旧版默认组合(pay.md 缺失/解析失败/无有效规则)——
$p = payOrderPrefix();
$stamp = date('YmdHis'); // 14位:20261006171418
$code = payRandomCode(4); // 4位校验码(去掉易混的 I/O/0/1)
return payGuardLen($p . $stamp . $code, '', $name, true);
}
}
-386
View File
@@ -1,386 +0,0 @@
<?php
require_once __DIR__ . '/../security.php'; // 第四层:全局限流(2026-09-30)
/**
* 支付补单器(poller_online.php)— 绕过 Cloudflare WAF 拦截的 HTTP 回调通知
*
* 工作原理:
* 直接读平台 MySQL 中已支付订单(status IN (1,2)) → 与本地 SQLite(pay_orders) 比对 →
* 将未处理的订单写入 web_transactions(recharge, pending) → 供 Java 高频定时器拉取 → addBonds 到账
*
* 触发方式(双保险):
* 1. 玩家在 Web 端查询订单时,pay.php 的 query_order 异步调用本脚本(前端触发)
* 2. Java 定时拉取 web_transactions 前,先调用本脚本(后台定时触发,最可靠)
*
* 完全自包含,不依赖 core.php,避免 500 错误。幂等:重复跑不会重复写流水。
*/
error_reporting(E_ALL);
ini_set('display_errors', 0);
// 只在直接执行时清除输出缓冲区,被包含时保留
if (!defined('POLLER_NO_AUTO_RUN')) {
while (ob_get_level() > 0) { ob_end_clean(); }
}
// ===== 配置(从 pay_secrets.php 加载,集中管理) =====
$secretsFile = __DIR__ . '/pay_secrets.php';
if (@is_file($secretsFile) && @is_readable($secretsFile)) {
@require_once $secretsFile;
}
// ★ 订单前缀读配置(2026-10-06 多服隔离):创单端 pay.php 用什么前缀,这里就扫什么前缀。
// 此前写死 'RE',测试服与生产服共用一个前缀 → 互相捡单串数据。
// ★★ 改前缀时,pay.php / poller_online.php / pay_poller.php / recharge_orders_api.php
// 四个文件必须一起改(它们现在都读同一个 PAY_ORDER_PREFIX,只要 config.php 改了就同步生效)。
require_once __DIR__ . '/pay_config.php';
$PLATFORM_DB_HOST = defined('PAY_MYSQL_HOST') ? PAY_MYSQL_HOST : '127.0.0.1';
$PLATFORM_DB_NAME = defined('PAY_MYSQL_DBNAME') ? PAY_MYSQL_DBNAME : 'caihong';
$PLATFORM_DB_USER = defined('PAY_MYSQL_USER') ? PAY_MYSQL_USER : 'hbye3AezRNk4r7YA';
$PLATFORM_DB_PASS = defined('PAY_MYSQL_PASS') ? PAY_MYSQL_PASS : '5HtD7Rn3seRAn2BE';
$GLOBALS['PLATFORM_DB_PREFIX'] = 'pay_'; $PLATFORM_DB_PREFIX = 'pay_';
// SQLite 数据库路径
$GLOBALS['SQLITE_DB_PATH'] = sqlDbFile('java'); // web_transactions (Java专属表 → Java.db)
$GLOBALS['ORDERS_DB_PATH'] = __DIR__ . '/../db/orders.db'; // pay_orders (PHP读写)
if (!function_exists('debugLog')) {
function debugLog($msg, $ctx = []) {
$logFile = __DIR__ . '/../db/debug.log';
$ts = date('Y-m-d H:i:s');
$entry = "[$ts] $msg";
if ($ctx) $entry .= ' | Context: ' . json_encode($ctx, JSON_UNESCAPED_UNICODE);
$entry .= "\n";
@file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX);
// ★ 保留策略(2026-10-05):只留最近3天 + ≤5MB,任一超标即清理到两条件同时满足
if (is_file(__DIR__ . '/../log_maintain.php')) {
@require_once __DIR__ . '/../log_maintain.php';
if (function_exists('logMaintain')) { @logMaintain($logFile); }
}
}
} // end if (!function_exists('debugLog'))
function getSQLite() {
// web.db — Java 高频读写(web_transactions 表)
static $db = null;
if ($db === null) {
$db = new SQLite3($GLOBALS['SQLITE_DB_PATH']);
$db->enableExceptions(true);
$db->exec('PRAGMA journal_mode=WAL');
$db->exec('PRAGMA busy_timeout=5000');
// web_transactions 表(Java 读取补单)
$db->exec("CREATE TABLE IF NOT EXISTS web_transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_name TEXT,
type TEXT,
amount INTEGER,
operator TEXT,
reason TEXT,
detail TEXT,
status TEXT DEFAULT 'pending',
created_at INTEGER
)");
@$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_web_tx_detail_type ON web_transactions(detail, type)");
}
return $db;
}
function getOrdersSQLite() {
// orders.db — PHP 订单库(pay_orders 表),独立于 Java 的 web.db
static $db = null;
if ($db === null) {
$db = new SQLite3($GLOBALS['ORDERS_DB_PATH']);
$db->enableExceptions(true);
$db->exec('PRAGMA journal_mode=WAL');
$db->exec('PRAGMA busy_timeout=5000');
// pay_orders 表
$db->exec("CREATE TABLE IF NOT EXISTS pay_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
out_trade_no TEXT UNIQUE,
trade_no TEXT,
player_name TEXT NOT NULL,
tier_id INTEGER DEFAULT 0,
money TEXT,
bond_amount INTEGER DEFAULT 0,
status TEXT DEFAULT 'created',
name TEXT,
platform_sign TEXT,
submit_params TEXT,
created_at INTEGER,
paid_at INTEGER
)");
}
return $db;
}
function getPlatformDB() {
try {
// ★ 直接用常量(从 pay_secrets.php 加载),不依赖全局变量
$host = defined('PAY_MYSQL_HOST') ? PAY_MYSQL_HOST : '127.0.0.1';
$dbname = defined('PAY_MYSQL_DBNAME') ? PAY_MYSQL_DBNAME : 'caihong';
$user = defined('PAY_MYSQL_USER') ? PAY_MYSQL_USER : 'hbye3AezRNk4r7YA';
$pass = defined('PAY_MYSQL_PASS') ? PAY_MYSQL_PASS : '5HtD7Rn3seRAn2BE';
// ★ 2026-10-05 日志收敛:连接过程(正在连接/DSN/连接成功)属于每次轮询的
// 常规路径,一律不写日志——补单器约 20 秒跑一次,这 3 行是 debug.log
// 膨胀的元凶之一。只有下面 catch 的"连接失败"(报错)才写日志。
$start = microtime(true);
$dsn = "mysql:host=$host;dbname=$dbname;charset=utf8mb4";
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_TIMEOUT => 15,
]);
return $pdo;
} catch (\Throwable $e) {
$elapsed = round((microtime(true) - ($start ?? microtime(true))) * 1000);
debugLog("[poller] MySQL连接失败", [
'error' => $e->getMessage(),
'code' => $e->getCode(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'elapsed_ms'=> $elapsed,
'dsn' => $dsn ?? 'undefined',
'user' => $user ?? 'undefined',
]);
throw $e;
}
}
/**
* 债券换算(仅当本地 pay_orders 无 bond_amount 时作为兜底估算)
* 0.01元 → 1债券;>=1元 → 金额*10债券
*/
function estimateBonds($money) {
$m = (float)$money;
if ($m >= 1.0) return (int)($m * 10);
return max(1, (int)($m * 100));
}
/**
* 进程锁:防止多个触发源(Java 定时、前端 query_order、管理员手动"立即对账")
* 并发跑补单造成重复写入 / 资源争用。非阻塞,若已被占用则跳过本次。
* 锁文件独立存在,进程退出后内核自动释放。
*/
function pollerAcquireLock() {
static $fp = null;
if ($fp === null) {
$lockPath = __DIR__ . '/../db/poller.lock';
$fp = @fopen($lockPath, 'c');
}
if ($fp === false) return false;
return flock($fp, LOCK_EX | LOCK_NB);
}
function pollPaidOrders() {
global $PLATFORM_DB_PREFIX;
$startTime = microtime(true);
// ★ 2026-10-05 日志收敛:不再无条件写"补单开始"。没有合适订单时下面直接
// 静默 return(只回 JSON,不写日志);只有真正补到单(成功)或出错才写。
// 并发保护:已有补单进程在跑则直接跳过,杜绝重复补单
if (!pollerAcquireLock()) {
echo json_encode(['result' => 'already_running', 'detail' => '另一个补单进程正在运行,本次跳过']);
return;
}
$sqlite = getOrdersSQLite(); // orders.db(pay_orders)
$webSqlite = getSQLite(); // web.db(web_transactions)
try {
$pdo = getPlatformDB();
} catch (\Throwable $e) {
debugLog('[poller_online] 平台数据库连接失败', [
'error' => $e->getMessage(),
'code' => $e->getCode(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'context' => 'pollPaidOrders',
]);
echo json_encode([
'error' => 'platform_db_connect_failed',
'detail' => $e->getMessage(),
'code' => $e->getCode(),
'file' => basename($e->getFile()),
'line' => $e->getLine(),
]);
return;
}
// ★ 2026-10-02 只查后端自己的订单:平台 MySQL 是多商户共用库,`pay_order` 里
// 绝大多数是其他商户/项目的单(纯数字单号)。以前不加过滤整表拉,平台每来一单
// 就被我们"捡走"、凭空写进本地 pay_orders + web_transactions,产生玩家=unknown
// 的垃圾充值单(Java 每次拉交易都刷屏处理它们),还顺手把别人的订单 notify
// 改成 1 —— 纯属多管闲事。本方单号由 pay.php createOrder 生成,前缀取自
// config.php 的 PAY_ORDER_PREFIX(生产 'RE' / 测试服 'RT' 等,各部署各配),
// 平台侧据此过滤,PHP 侧再兜底校验一次;平台其他订单一概不读不写。
// 仍不过滤 notify:平台可能已标记 notify=1 但本地 DB 未更新(HTTP 回调被
// CF WAF 拦截),是否补单以本地 pay_orders 状态为准。
$prefix = $PLATFORM_DB_PREFIX;
// ★ 2026-10-07 改为【定向补单】:不再按前缀批量扫平台共享库,
// 而是先取本地 pay_orders 里所有未补单(非 paid)的【完整订单号】,
// 再让平台只按这些完整订单号精确匹配。这样就算生产/测试共用 CYZJ 前缀,
// 测试服脚本也只会查它自己本地活跃的订单号,抓不到生产服的单。
$activeStmt = $sqlite->prepare("SELECT out_trade_no FROM pay_orders WHERE status != 'paid' AND out_trade_no IS NOT NULL AND out_trade_no != ''");
// ★ 2026-10-08 实测修正:SQLite3Stmt::execute() 直接返回 SQLite3Result(PHP 7.4/8.2/8.5
// 三个版本实测一致),Stmt/Result 上都没有 getResult() 方法。此前一版写
// $activeStmt->fetchArray()(Stmt 无此方法→致命错误)、上一版写 getResult()
// (方法不存在→致命错误),补单器其实一直没跑起来过。
$activeRes = $activeStmt->execute();
$activeNos = array();
while ($activeRes && ($a = $activeRes->fetchArray(SQLITE3_ASSOC)) !== false) {
$activeNos[] = (string)$a['out_trade_no'];
}
if (empty($activeNos)) {
echo json_encode(['result' => 'no_local_active_orders']);
return;
}
// 平台侧按完整订单号精确匹配(IN 子句,最多取前 200 个,足够覆盖并发场景)
$activeNos = array_slice($activeNos, 0, 200);
$inList = "'" . implode("','", array_map('addslashes', $activeNos)) . "'";
$stmt = $pdo->prepare("SELECT out_trade_no, trade_no, uid, money, status, notify, param, version, addtime FROM `{$prefix}order` WHERE out_trade_no IN (" . $inList . ") AND status IN (1, 2)");
$stmt->execute();
$allOrders = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($allOrders)) {
echo json_encode(['result' => 'no_paid_orders_on_platform', 'active_local' => count($activeNos)]);
return;
}
// 只保留「本地确实在跟踪、且还没补单」的订单(二次过滤,防本地已被补单/已支付)
$orders = [];
foreach ($allOrders as $o) {
$outNo = (string)$o['out_trade_no'];
if (!in_array($outNo, $activeNos, true)) {
continue;
}
$check = $sqlite->prepare("SELECT status FROM pay_orders WHERE out_trade_no = :no");
$check->bindValue(':no', $outNo, SQLITE3_TEXT);
$checkRes = $check->execute();
$row = $checkRes ? $checkRes->fetchArray(SQLITE3_ASSOC) : false;
if (!$row || $row['status'] !== 'paid') {
$orders[] = $o;
}
}
if (empty($orders)) {
echo json_encode(['result' => 'no_pending_orders', 'active_local' => count($activeNos)]);
return;
}
$processed = 0;
$skipped = 0;
$now = time();
foreach ($orders as $order) {
$outTradeNo = $order['out_trade_no'];
$tradeNo = $order['trade_no'];
$playerName = $order['param'] ?? 'unknown';
$money = (string)$order['money'];
// 查本地 pay_orders(拿到正确的 bond_amount,创建订单时按档位设置)
$check = $sqlite->prepare("SELECT status, bond_amount FROM pay_orders WHERE out_trade_no = :no");
$check->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$checkRes = $check->execute();
$row = $checkRes ? $checkRes->fetchArray(SQLITE3_ASSOC) : false;
if ($row && $row['status'] === 'paid') {
$skipped++;
markNotified($pdo, $tradeNo);
continue;
}
// 债券数:优先用本地订单记录的 bond_amount(最准确),否则估算
$bonds = 0;
if ($row && !empty($row['bond_amount'])) {
$bonds = (int)$row['bond_amount'];
} else {
$bonds = estimateBonds($money);
}
debugLog('[poller_online] 补单处理', [
'out_trade_no' => $outTradeNo,
'player' => $playerName,
'money' => $money,
'bonds' => $bonds,
'platform_notify'=> $order['notify'],
'has_local_order'=> $row ? 1 : 0,
]);
// 写/更新本地 pay_orders
if (!$row) {
$ins = $sqlite->prepare("INSERT OR IGNORE INTO pay_orders (out_trade_no, trade_no, player_name, money, bond_amount, status, created_at, paid_at) VALUES (:no, :tn, :p, :m, :b, 'paid', :t, :t)");
$ins->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$ins->bindValue(':tn', $tradeNo, SQLITE3_TEXT);
$ins->bindValue(':p', $playerName, SQLITE3_TEXT);
$ins->bindValue(':m', $money, SQLITE3_TEXT);
$ins->bindValue(':b', $bonds, SQLITE3_INTEGER);
$ins->bindValue(':t', $now, SQLITE3_INTEGER);
$ins->execute();
} else {
$upd = $sqlite->prepare("UPDATE pay_orders SET status='paid', trade_no=:tn, paid_at=:t, bond_amount=:b WHERE out_trade_no=:no AND status != 'paid'");
$upd->bindValue(':tn', $tradeNo, SQLITE3_TEXT);
$upd->bindValue(':t', $now, SQLITE3_INTEGER);
$upd->bindValue(':b', $bonds, SQLITE3_INTEGER);
$upd->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$upd->execute();
}
// 写 web_transactions(幂等:INSERT OR IGNORE 防止竞态条件重复插入)→ web.db
$txIns = $webSqlite->prepare("INSERT OR IGNORE INTO web_transactions (player_name, type, amount, operator, reason, detail, status, created_at) VALUES (:p, 'recharge', :a, '支付平台(补单)', '在线充值(支付宝)', :d, 'pending', :t)");
$txIns->bindValue(':p', $playerName, SQLITE3_TEXT);
$txIns->bindValue(':a', $bonds, SQLITE3_INTEGER);
$txIns->bindValue(':d', $outTradeNo, SQLITE3_TEXT);
$txIns->bindValue(':t', $now, SQLITE3_INTEGER);
$txIns->execute();
// 检查是否是新插入的(affected_rows > 0 表示新插入,=0 表示已存在被忽略)
if ($webSqlite->changes() > 0) {
debugLog('[poller_online] 充值交易已写入 web_transactions', [
'player' => $playerName, 'bonds' => $bonds,
'out_trade_no' => $outTradeNo, 'money' => $money,
]);
}
markNotified($pdo, $tradeNo);
$processed++;
}
$elapsed = round((microtime(true) - $startTime) * 1000);
// ★ 2026-10-05 日志收敛:只在本轮真正补到单(processed > 0)时写"补单完成";
// 没有实际补单动作则静默,不再每轮刷一条。
if ($processed > 0) {
debugLog('[poller] 补单完成', ['processed' => $processed, 'skipped' => $skipped, 'elapsed_ms' => $elapsed]);
}
echo json_encode([
'result' => 'ok',
'processed' => $processed,
'skipped' => $skipped,
'foreign_skipped' => 0,
'total' => count($orders),
'elapsed_ms' => $elapsed,
]);
}
function markNotified($pdo, $tradeNo) {
global $PLATFORM_DB_PREFIX;
try {
$prefix = $PLATFORM_DB_PREFIX;
$stmt = $pdo->prepare("UPDATE {$prefix}order SET notify = 1 WHERE trade_no = ? AND notify = 0");
$stmt->execute([$tradeNo]);
} catch (\Throwable $e) {
debugLog('[poller_online] 标记notify失败: ' . $e->getMessage(), ['trade_no' => $tradeNo]);
}
}
// ===== 入口 =====
// 若被其它脚本 include 并定义了 POLLER_NO_AUTO_RUN,则只暴露函数(getPlatformDB 等),
// 不在此处自动执行补单(由调用方自行决定何时触发)。
if (!defined('POLLER_NO_AUTO_RUN')) {
header('Content-Type: application/json; charset=utf-8');
try {
pollPaidOrders();
} catch (\Throwable $e) {
debugLog('[poller_online] 未捕获异常', ['error' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine()]);
echo json_encode(['error' => 'uncaught_exception', 'detail' => $e->getMessage()]);
}
}
-698
View File
@@ -1,698 +0,0 @@
<?php
/**
* 充值订单API(recharge_orders_api.php)
* 提供充值订单查询、统计、筛选功能
*/
error_reporting(E_ALL);
ini_set('display_errors', 0);
ob_start();
require_once __DIR__ . '/../core.php';
ob_end_clean();
// 加载支付密钥(含MySQL凭据)
$secretsFile = __DIR__ . '/pay_secrets.php';
if (@is_file($secretsFile) && @is_readable($secretsFile)) {
@require_once $secretsFile;
}
// ★ 订单前缀读配置(2026-10-06 多服隔离):与 pay.php 创单端共用同一份 PAY_ORDER_PREFIX
require_once __DIR__ . '/pay_config.php';
/**
* 获取平台MySQL数据库连接(集中凭据管理)
* 注意:poller_online.php 也有同名函数,此处用不同名称避免 redeclare 冲突
*/
function getAdminPlatformDB() {
$host = defined('PAY_MYSQL_HOST') ? PAY_MYSQL_HOST : '127.0.0.1';
$dbname = defined('PAY_MYSQL_DBNAME') ? PAY_MYSQL_DBNAME : 'caihong';
$user = defined('PAY_MYSQL_USER') ? PAY_MYSQL_USER : 'hbye3AezRNk4r7YA';
$pass = defined('PAY_MYSQL_PASS') ? PAY_MYSQL_PASS : '5HtD7Rn3seRAn2BE';
$pdo = new PDO(
"mysql:host=$host;dbname=$dbname;charset=utf8mb4",
$user,
$pass,
[PDO::ATTR_TIMEOUT => 15, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
return $pdo;
}
/**
* 确保充值商店配置表存在
*/
function ensureShopConfigsTable($db) {
$db->exec("CREATE TABLE IF NOT EXISTS shop_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_name TEXT NOT NULL DEFAULT '',
stock INTEGER NOT NULL DEFAULT -1,
temporary_offer TEXT NOT NULL DEFAULT '',
offer_expire TEXT NOT NULL DEFAULT '',
price REAL NOT NULL DEFAULT 0,
discount_price REAL NOT NULL DEFAULT 0,
bond_reward TEXT NOT NULL DEFAULT '1-1',
is_active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0
)");
// 兼容旧表:检查并添加discount_price字段
$hasCol = false;
$cols = $db->query("PRAGMA table_info(shop_configs)");
while ($col = $cols->fetchArray(SQLITE3_ASSOC)) {
if ($col['name'] === 'discount_price') { $hasCol = true; break; }
}
if (!$hasCol) {
$db->exec("ALTER TABLE shop_configs ADD COLUMN discount_price REAL NOT NULL DEFAULT 0");
}
}
// 检查管理员登录
if (!isAdminLoggedIn()) {
error('无权限访问', 403);
}
$action = getParam('action', '');
switch ($action) {
case 'list':
handleOrderList();
break;
case 'detail':
handleOrderDetail();
break;
case 'shop_config':
handleShopConfig();
break;
case 'reconcile':
handleReconcile();
break;
case 'sync_platform':
handleSyncFromPlatform();
break;
default:
error('未知操作');
}
/**
* 获取订单列表
*/
function handleOrderList() {
// ★ 使用独立订单库,不和Java共享web.db
$db = getOrdersDB();
ensurePayOrdersTable($db);
// ★ 增量迁移:从 web.db 同步缺失的 pay_orders 到 orders.db(处理历史数据)
try {
$webDb = getDB();
$tableCheck = $webDb->query("SELECT name FROM sqlite_master WHERE type='table' AND name='pay_orders'");
if ($tableCheck->fetchArray()) {
$migrateData = $webDb->query("SELECT * FROM pay_orders");
$ins = $db->prepare("INSERT OR IGNORE INTO pay_orders (out_trade_no, trade_no, player_name, tier_id, money, bond_amount, status, name, platform_sign, submit_params, created_at, paid_at) VALUES (:no, :tn, :p, :ti, :m, :b, :s, :n, :ps, :sp, :ca, :pa)");
$migrated = 0;
while ($row = $migrateData->fetchArray(SQLITE3_ASSOC)) {
$ins->bindValue(':no', $row['out_trade_no'] ?? '', SQLITE3_TEXT);
$ins->bindValue(':tn', $row['trade_no'] ?? '', SQLITE3_TEXT);
$ins->bindValue(':p', $row['player_name'] ?? '', SQLITE3_TEXT);
$ins->bindValue(':ti', $row['tier_id'] ?? 0, SQLITE3_INTEGER);
$ins->bindValue(':m', $row['money'] ?? '0', SQLITE3_TEXT);
$ins->bindValue(':b', $row['bond_amount'] ?? 0, SQLITE3_INTEGER);
$ins->bindValue(':s', $row['status'] ?? 'created', SQLITE3_TEXT);
$ins->bindValue(':n', $row['name'] ?? '', SQLITE3_TEXT);
$ins->bindValue(':ps', $row['platform_sign'] ?? '', SQLITE3_TEXT);
$ins->bindValue(':sp', $row['submit_params'] ?? '', SQLITE3_TEXT);
$ins->bindValue(':ca', $row['created_at'] ?? 0, SQLITE3_INTEGER);
$ins->bindValue(':pa', $row['paid_at'] ?? 0, SQLITE3_INTEGER);
$ins->execute();
if ($db->changes() > 0) $migrated++;
}
if ($migrated > 0) {
debugLog('[handleOrderList] 增量迁移web.db→orders.db', ['migrated' => $migrated]);
}
}
} catch (Exception $e) {
debugLog('[handleOrderList] 迁移失败', ['error' => $e->getMessage()]);
}
$player = getParam('player', '');
$orderNo = getParam('order_no', '');
$status = getParam('status', '');
$limit = min(200, max(1, (int)getParam('limit', 50)));
// 构建查询
$conditions = [];
$params = [];
if ($player) {
$conditions[] = "player_name LIKE :player";
$params[':player'] = '%' . $player . '%';
}
if ($orderNo) {
$conditions[] = "out_trade_no LIKE :order_no";
$params[':order_no'] = '%' . $orderNo . '%';
}
if ($status) {
$conditions[] = "status = :status";
$params[':status'] = $status;
}
$where = '';
if ($conditions) {
$where = 'WHERE ' . implode(' AND ', $conditions);
}
// 查询订单
$sql = "SELECT * FROM pay_orders $where ORDER BY created_at DESC LIMIT :limit";
$stmt = $db->prepare($sql);
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v, SQLITE3_TEXT);
}
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$orders = [];
$result = $stmt->execute();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$orders[] = $row;
}
// 统计数据
$statsSql = "SELECT
COUNT(*) as total,
SUM(CASE WHEN status='paid' THEN 1 ELSE 0 END) as paid,
SUM(CASE WHEN status='created' THEN 1 ELSE 0 END) as created,
SUM(CASE WHEN status='paid' THEN CAST(money AS REAL) ELSE 0 END) as totalMoney
FROM pay_orders";
$statsResult = $db->querySingle($statsSql, true);
$stats = [
'total' => $statsResult['total'] ?? 0,
'paid' => $statsResult['paid'] ?? 0,
'created' => $statsResult['created'] ?? 0,
'totalMoney' => number_format($statsResult['totalMoney'] ?? 0, 2),
];
success(['orders' => $orders, 'stats' => $stats]);
}
/**
* 获取单个订单详情
*/
function handleOrderDetail() {
$outTradeNo = getParam('out_trade_no');
if (!$outTradeNo) {
error('缺少订单号');
}
// ★ 使用独立订单库
$db = getOrdersDB();
ensurePayOrdersTable($db);
$stmt = $db->prepare("SELECT * FROM pay_orders WHERE out_trade_no = :no");
$stmt->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) {
error('订单不存在');
}
success(['order' => $row]);
}
/**
* 手动触发对账(调用 poller_online.php)
*/
function handleReconcile() {
// 直接包含 poller_online.php 并调用 pollPaidOrders
define('POLLER_NO_AUTO_RUN', true);
// ★ 清空所有已有缓冲区(防止任何HTML/警告污染JSON)
while (ob_get_level() > 0) { ob_end_clean(); }
// 包含 poller_online.php(定义函数,不执行)
require_once __DIR__ . '/poller_online.php';
// 记录对账开始
if (function_exists('debugLog')) {
debugLog('[reconcile] 开始对账', [
'admin' => $_SESSION['admin_user'] ?? 'unknown',
'time' => date('Y-m-d H:i:s'),
]);
}
// 开始对账 - 捕获所有输出
ob_start();
try {
pollPaidOrders();
} catch (\Throwable $e) {
// 捕获异常,输出JSON错误
while (ob_get_level() > 0) { ob_end_clean(); }
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'poller_exception', 'detail' => $e->getMessage()], JSON_UNESCAPED_UNICODE);
exit;
}
$output = ob_get_clean();
// 记录原始输出
if (function_exists('debugLog')) {
debugLog('[reconcile] pollPaidOrders输出', [
'output_len' => strlen($output),
'output' => substr($output, 0, 500),
]);
}
// 解析结果
$result = json_decode($output, true);
if (!$result || !is_array($result)) {
if (function_exists('debugLog')) {
debugLog('[reconcile] JSON解析失败', ['output' => substr($output, 0, 200)]);
}
// 确保输出干净的JSON
while (ob_get_level() > 0) { ob_end_clean(); }
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'poller_output_invalid', 'raw' => substr($output, 0, 100)], JSON_UNESCAPED_UNICODE);
exit;
}
// 记录解析结果
if (function_exists('debugLog')) {
debugLog('[reconcile] 解析结果', [
'result' => $result,
]);
}
// 直接输出结果
while (ob_get_level() > 0) { ob_end_clean(); }
header('Content-Type: application/json; charset=utf-8');
echo json_encode($result, JSON_UNESCAPED_UNICODE);
exit;
}
/**
* 从平台MySQL同步所有已支付订单到本地
*/
function handleSyncFromPlatform() {
// ★ 使用独立订单库,不和Java共享web.db(Java高频写入会锁死SQLite)
$db = getOrdersDB();
ensurePayOrdersTable($db);
// 注意:web_transactions 表只在 web.db 中(Java读取),不在 orders.db 中
try {
$pdo = getAdminPlatformDB();
// ★ 2026-10-02 只查后端自己的订单:平台 MySQL 是多商户共用库,整表拉 200 条
// 会把其他商户/项目的单(纯数字单号)也同步成本地充值单,凭空造出
// 玩家=unknown 的垃圾订单。本方单号前缀取自 config.php 的 PAY_ORDER_PREFIX
// (pay.php createOrder 用同一个值生成),平台侧据此过滤,PHP 侧再兜底校验;
// 平台其他订单一概不碰。
// ★ 2026-10-06:前缀改为读配置(原来写死 'RE',测试服改前缀后这里不跟着改
// 就会把生产服的单同步进来)。
// ★ 2026-10-07:前缀第一来源改为站点根 pay.md「前缀码」;扫描改为「当前前缀+历史前缀」
// 双条件(payOrderPrefixLikeAny),改过前缀码后在途老单仍能同步、不丢单。
$stmt = $pdo->prepare("SELECT out_trade_no, trade_no, uid, money, status, param, addtime FROM `pay_order` WHERE (" . payOrderPrefixLikeAny() . ") AND status IN (1, 2) ORDER BY addtime DESC LIMIT 200");
$stmt->execute();
$platformOrders = $stmt->fetchAll(PDO::FETCH_ASSOC);
$synced = 0;
$alreadyPaid = 0;
$foreign = 0;
$errors = [];
$now = time();
foreach ($platformOrders as $po) {
$outTradeNo = $po['out_trade_no'];
// ★ 兜底校验:与创单端读同一份 PAY_ORDER_PREFIX
if (!payOrderIsOurs($outTradeNo)) {
$foreign++; // 非本方订单:不建单、不写流水
continue;
}
$player = $po['param'] ?? 'unknown';
$tradeNo = $po['trade_no'] ?? '';
$money = (string)$po['money'];
// ★ 债券换算保持原始行为(2026-10-06 用户指示:代码不干预换算比例,
// 金额→券数由后台「面板-充值商店配置」决定,运维自行调整)。
// 本行仅在本地无档位记录时作兜底估算,公式与改造前完全一致。
$bonds = ($money >= '1.0') ? (int)($money * 10) : max(1, (int)($money * 100));
// 查本地是否已有
$check = $db->prepare("SELECT id, status FROM pay_orders WHERE out_trade_no = :no");
$check->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$localRow = $check->execute()->fetchArray(SQLITE3_ASSOC);
if ($localRow && $localRow['status'] === 'paid') {
$alreadyPaid++;
continue;
}
if ($localRow) {
// 已有记录但未标记paid → 更新
$upd = $db->prepare("UPDATE pay_orders SET status='paid', trade_no=:tn, paid_at=:t WHERE out_trade_no=:no AND status != 'paid'");
$upd->bindValue(':tn', $tradeNo, SQLITE3_TEXT);
$upd->bindValue(':t', $now, SQLITE3_INTEGER);
$upd->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$upd->execute();
} else {
// 本地无记录 → 创建
$ins = $db->prepare("INSERT OR IGNORE INTO pay_orders (out_trade_no, trade_no, player_name, money, bond_amount, status, name, submit_params, created_at, paid_at) VALUES (:no, :tn, :p, :m, :b, 'paid', :n, '', :t, :t)");
$ins->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
$ins->bindValue(':tn', $tradeNo, SQLITE3_TEXT);
$ins->bindValue(':p', $player, SQLITE3_TEXT);
$ins->bindValue(':m', $money, SQLITE3_TEXT);
$ins->bindValue(':b', $bonds, SQLITE3_INTEGER);
$ins->bindValue(':n', '债券充值', SQLITE3_TEXT);
$ins->bindValue(':t', $now, SQLITE3_INTEGER);
$ins->execute();
}
// 写 web_transactions(幂等)— 注意:web_transactions 在 web.db 中(Java读取),不在 orders.db
try {
$webDb = getDB(); // 获取 web.db 连接
ensureWebTransactionsTable($webDb);
$txCheck = $webDb->prepare("SELECT id FROM web_transactions WHERE detail = :d AND type = 'recharge' LIMIT 1");
$txCheck->bindValue(':d', $outTradeNo, SQLITE3_TEXT);
$txRow = $txCheck->execute()->fetchArray(SQLITE3_ASSOC);
if (!$txRow) {
$txIns = $webDb->prepare("INSERT OR IGNORE INTO web_transactions (player_name, type, amount, operator, reason, detail, status, created_at) VALUES (:p, 'recharge', :a, '管理员同步', '在线充值(支付宝)', :d, 'pending', :t)");
$txIns->bindValue(':p', $player, SQLITE3_TEXT);
$txIns->bindValue(':a', $bonds, SQLITE3_INTEGER);
$txIns->bindValue(':d', $outTradeNo, SQLITE3_TEXT);
$txIns->bindValue(':t', $now, SQLITE3_INTEGER);
$txIns->execute();
}
} catch (Exception $e) {
// web_transactions 写入失败不影响主流程(poller 会兜底)
debugLog('[sync_platform] web_transactions写入失败', ['out_trade_no' => $outTradeNo, 'error' => $e->getMessage()]);
}
$synced++;
}
success([
'platform_total' => count($platformOrders),
'synced' => $synced,
'already_paid' => $alreadyPaid,
'foreign_skipped' => $foreign,
], "同步完成:平台{$synced}笔已同步,{$alreadyPaid}笔已存在,{$foreign}笔非本方订单已忽略");
} catch (\Throwable $e) {
error('同步失败: ' . $e->getMessage());
}
}
/**
* 确保 web_transactions 表存在
*/
function ensureWebTransactionsTable($db) {
$db->exec("CREATE TABLE IF NOT EXISTS web_transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_name TEXT NOT NULL,
type TEXT,
amount INTEGER DEFAULT 0,
operator TEXT,
reason TEXT,
detail TEXT,
status TEXT DEFAULT 'pending',
created_at INTEGER
)");
@$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_web_tx_detail_type ON web_transactions(detail, type)");
}
function ensurePayOrdersTable($db) {
$db->exec("CREATE TABLE IF NOT EXISTS pay_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
out_trade_no TEXT UNIQUE,
trade_no TEXT,
player_name TEXT NOT NULL,
tier_id INTEGER DEFAULT 0,
money TEXT,
bond_amount INTEGER DEFAULT 0,
status TEXT DEFAULT 'created',
name TEXT,
platform_sign TEXT,
submit_params TEXT,
created_at INTEGER,
paid_at INTEGER
)");
// 检查并添加缺失的列
$hasCol = false;
$res = $db->query("PRAGMA table_info(pay_orders)");
while ($col = $res->fetchArray(SQLITE3_ASSOC)) {
if ($col['name'] === 'submit_params') { $hasCol = true; break; }
}
if (!$hasCol) {
$db->exec("ALTER TABLE pay_orders ADD COLUMN submit_params TEXT");
}
}
/**
* 处理充值商店配置
*/
function handleShopConfig() {
$db = getOrdersDB();
ensureShopConfigsTable($db);
$method = getParam('method', 'list');
switch ($method) {
case 'list':
handleShopConfigList($db);
break;
case 'save':
handleShopConfigSave($db);
break;
case 'delete':
handleShopConfigDelete($db);
break;
case 'toggle':
handleShopConfigToggle($db);
break;
default:
error('未知操作');
}
}
/**
* 获取商店配置列表
*/
function handleShopConfigList($db) {
$stmt = $db->query("SELECT * FROM shop_configs ORDER BY id DESC");
$configs = [];
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
// 解析有效期
$expireInfo = parseExpireDate($row['offer_expire']);
$row['expire_valid'] = $expireInfo['valid'];
$row['expire_timestamp'] = $expireInfo['timestamp'];
$row['expire_display'] = $expireInfo['display'];
$configs[] = $row;
}
success($configs);
}
/**
* 保存商店配置(添加或编辑)
*/
function handleShopConfigSave($db) {
$id = intval(getParam('id', 0));
$itemName = getParam('item_name', '');
$stock = intval(getParam('stock', -1));
$temporaryOffer = getParam('temporary_offer', '');
$offerExpire = getParam('offer_expire', '');
$price = floatval(getParam('price', 0));
$discountPriceRaw = getParam('discount_price', '0');
$bondReward = getParam('bond_reward', '1-1');
$isActive = intval(getParam('is_active', 1));
// 验证库存
if ($stock < -1) {
error('库存不能小于-1');
}
// 验证售价
if ($price <= 0) {
error('售价必须大于0');
}
// 解析优惠后价格
// 支持两种格式:
// 1. 直接价格(如 1、2、1.5)→ 直接使用
// 2. 折扣(如 0.9、0.8)→ 计算 原价 × 折扣
// 硬锁规则:
// - 0.01元以下的商品不支持折扣
// - 优惠后价格必须小于原价
$discountPrice = 0;
$discountPriceRaw = trim($discountPriceRaw);
// 0.01元以下的商品不支持折扣
if ($price < 0.01) {
if ($discountPriceRaw !== '' && $discountPriceRaw !== '0') {
error('售价低于0.01元的商品不支持折扣');
}
} elseif ($discountPriceRaw !== '' && $discountPriceRaw !== '0') {
$discountValue = floatval($discountPriceRaw);
if ($discountValue <= 0) {
error('优惠后价格必须大于0');
}
// 判断是直接价格还是折扣(<1的数认为是折扣)
if ($discountValue < 1) {
// 折扣:原价 × 折扣
$discountPrice = round($price * $discountValue, 2);
} else {
// 直接价格
$discountPrice = round($discountValue, 2);
}
// 硬锁:优惠后价格必须小于原价
if ($discountPrice >= $price) {
error('优惠后价格必须小于原价(当前原价: ' . number_format($price, 2) . '元)');
}
// 硬锁:优惠后价格不能低于0.01元
if ($discountPrice < 0.01) {
error('优惠后价格不能低于0.01元');
}
}
// 验证债券范围格式(支持盲盒范围,如"1-10")
if (!preg_match('/^\d+(-\d+)?$/', $bondReward)) {
error('债券范围格式错误,应为数字或范围如"1-10"');
}
// 解析并验证有效期
$expireInfo = parseExpireDate($offerExpire);
if ($offerExpire && !$expireInfo['valid']) {
error('有效期格式错误,请使用yyyy-MM-dd或中文格式如"2026年7月16日"');
}
$now = time();
if ($id > 0) {
// 编辑
$stmt = $db->prepare("UPDATE shop_configs SET
item_name = :name,
stock = :stock,
temporary_offer = :offer,
offer_expire = :expire,
price = :price,
discount_price = :discount_price,
bond_reward = :bond,
is_active = :active,
updated_at = :updated
WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
} else {
// 添加
$stmt = $db->prepare("INSERT INTO shop_configs
(item_name, stock, temporary_offer, offer_expire, price, discount_price, bond_reward, is_active, created_at, updated_at)
VALUES (:name, :stock, :offer, :expire, :price, :discount_price, :bond, :active, :created, :updated)");
$stmt->bindValue(':created', $now, SQLITE3_INTEGER);
}
$stmt->bindValue(':name', $itemName, SQLITE3_TEXT);
$stmt->bindValue(':stock', $stock, SQLITE3_INTEGER);
$stmt->bindValue(':offer', $temporaryOffer, SQLITE3_TEXT);
$stmt->bindValue(':expire', $offerExpire, SQLITE3_TEXT);
$stmt->bindValue(':price', $price, SQLITE3_FLOAT);
$stmt->bindValue(':discount_price', $discountPrice, SQLITE3_FLOAT);
$stmt->bindValue(':bond', $bondReward, SQLITE3_TEXT);
$stmt->bindValue(':active', $isActive, SQLITE3_INTEGER);
$stmt->bindValue(':updated', $now, SQLITE3_INTEGER);
$stmt->execute();
$savedId = $id > 0 ? $id : $db->lastInsertRowID();
success(['id' => $savedId, 'discount_price' => $discountPrice], $id > 0 ? '配置已更新' : '配置已添加');
}
/**
* 删除商店配置
*/
function handleShopConfigDelete($db) {
$id = intval(getParam('id', 0));
if ($id <= 0) {
error('无效的配置ID');
}
$stmt = $db->prepare("DELETE FROM shop_configs WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
if ($db->changes() > 0) {
success([], '配置已删除');
} else {
error('配置不存在');
}
}
/**
* 切换商店配置上下架状态
*/
function handleShopConfigToggle($db) {
$id = intval(getParam('id', 0));
if ($id <= 0) {
error('无效的配置ID');
}
$stmt = $db->prepare("UPDATE shop_configs SET is_active = CASE WHEN is_active = 1 THEN 0 ELSE 1 END, updated_at = :updated WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->bindValue(':updated', time(), SQLITE3_INTEGER);
$stmt->execute();
if ($db->changes() > 0) {
// 获取新状态
$check = $db->prepare("SELECT is_active FROM shop_configs WHERE id = :id");
$check->bindValue(':id', $id, SQLITE3_INTEGER);
$row = $check->execute()->fetchArray(SQLITE3_ASSOC);
$newStatus = $row ? $row['is_active'] : 0;
success(['is_active' => $newStatus], $newStatus ? '已上架' : '已下架');
} else {
error('配置不存在');
}
}
/**
* 解析有效期日期(支持yyyy-MM-dd和中文格式如"2026年7月16日")
*/
function parseExpireDate($expireStr) {
if (empty($expireStr)) {
return ['valid' => true, 'timestamp' => 0, 'display' => '长期有效'];
}
$expireStr = trim($expireStr);
// 尝试解析yyyy-MM-dd格式
if (preg_match('/^\d{4}-\d{1,2}-\d{1,2}$/', $expireStr)) {
$time = strtotime($expireStr . ' 23:59:59');
if ($time !== false) {
return [
'valid' => true,
'timestamp' => $time,
'display' => date('Y-m-d', $time) . ' 到期'
];
}
}
// 尝试解析中文格式(如"2026年7月16日")
if (preg_match('/(\d{4})年(\d{1,2})月(\d{1,2})日/', $expireStr, $matches)) {
$year = $matches[1];
$month = $matches[2];
$day = $matches[3];
$time = mktime(23, 59, 59, $month, $day, $year);
if ($time !== false) {
return [
'valid' => true,
'timestamp' => $time,
'display' => "{$year}-{$month}-{$day} 到期"
];
}
}
// 尝试使用strtotime解析其他格式
$time = strtotime($expireStr);
if ($time !== false && $time > time()) {
return [
'valid' => true,
'timestamp' => $time,
'display' => date('Y-m-d', $time) . ' 到期'
];
}
return ['valid' => false, 'timestamp' => 0, 'display' => '无效日期'];
}
-329
View File
@@ -1,329 +0,0 @@
<?php
/**
* 注册API
* POST ?action=check - 检查用户名是否已注册
* POST ?action=register - 注册新账号(需token)
* GET ?action=query - 查询账号信息(需token)
*/
// 防止任何输出污染JSON响应
ob_start();
require_once __DIR__ . '/../core.php';
ob_end_clean();
$action = getParam('action', 'check');
$token = getParam('token');
try {
switch ($action) {
case 'check':
registerCheck();
break;
case 'register':
registerAccount($token);
break;
case 'web_register':
webRegister();
break;
case 'query':
registerQuery($token);
break;
default:
error('未知操作: ' . $action);
}
// ★ WAL checkpoint:释放WAL锁,减少database is locked概率
try { walCheckpoint(); } catch (\Throwable $ignored) {}
} catch (\Throwable $e) {
while (ob_get_level() > 0) ob_end_clean();
error('服务器内部错误: ' . $e->getMessage(), 500);
}
// ===== 检查用户名 =====
function registerCheck() {
$player = getParam('player');
if (!$player) error('缺少player参数');
$db = getDB();
// 只查询users表实际存在的列
$stmt = $db->prepare("SELECT player_name, register_time, email FROM users WHERE player_name = :name");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
success([
'exists' => !!$row,
'player' => $player,
'info' => $row ?: null
]);
}
// ===== 注册账号 =====
function registerAccount($token) {
$player = getParam('player');
$passwordHash = getParam('password_hash');
$salt = getParam('salt');
$email = getParam('email', '');
$ip = getParam('ip', '');
if (!$player) error('缺少player');
if (!$passwordHash) error('缺少password_hash');
if (!$salt) error('缺少salt');
// ★ 第56批:邮箱后缀校验(与Java端一致)
if ($email !== '' && !checkEmailSuffix($email)) {
$mode = '默认';
$cfgDb = getDB();
$cfgRow = $cfgDb->query("SELECT mode FROM email_suffix_config WHERE id = 1")->fetchArray(SQLITE3_ASSOC);
if ($cfgRow) $mode = (string)$cfgRow['mode'];
if ($mode === '白名单') error('该邮箱后缀不在允许列表中');
else if ($mode === '黑名单') error('该邮箱后缀被禁止使用');
else error('该邮箱后缀不可用');
}
// 验证token(注册需要token)
$isPreview = true;
if ($token) {
$tokenInfo = validateToken($token);
if ($tokenInfo) {
$isPreview = false;
validateAndUseToken($token);
}
}
$db = getDB();
// 检查是否已注册
$stmt = $db->prepare("SELECT 1 FROM users WHERE player_name = :name");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$result = $stmt->execute();
if ($result->fetchArray()) {
error('账号已存在: ' . $player);
}
if ($isPreview) {
preview([
'player' => $player,
'email' => $email,
'would_register' => true
], '预览模式 - 注册不会生效');
}
// 执行注册
$now = time();
$stmt = $db->prepare("INSERT INTO users (player_name, register_time, email) VALUES (:name, :time, :email)");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
$stmt->bindValue(':email', $email, SQLITE3_TEXT);
$stmt->execute();
// 记录
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, created_at) VALUES (:player, 'register', 0, 'Web端注册', :time)");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
$stmt->execute();
success(['player' => $player], '注册成功');
}
// ===== Web注册请求(由Web端提交,等待插件同步)=====
function webRegister() {
$webToken = getParam('web_token');
$password = getParam('password');
$email = getParam('email', '');
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!$webToken) error('缺少web_token');
if (!$password) error('缺少password');
if (strlen($password) < 6) error('密码长度至少6位');
// ★ 第56批:邮箱后缀校验(与Java端一致)
if ($email !== '' && !checkEmailSuffix($email)) {
$mode = '默认';
$cfgDb = getDB();
$cfgRow = $cfgDb->query("SELECT mode FROM email_suffix_config WHERE id = 1")->fetchArray(SQLITE3_ASSOC);
if ($cfgRow) $mode = (string)$cfgRow['mode'];
if ($mode === '白名单') error('该邮箱后缀不在允许列表中');
else if ($mode === '黑名单') error('该邮箱后缀被禁止使用');
else error('该邮箱后缀不可用');
}
// 验证web_token
$db = getDB();
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE web_token = :token");
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
$result = $stmt->execute();
$tokenRow = $result->fetchArray(SQLITE3_ASSOC);
if (!$tokenRow) error('无效的Web登录Token');
$createdAt = (int)($tokenRow['created_at'] ?? 0);
$expireSeconds = (int)($tokenRow['expire_seconds'] ?? 600);
if (time() - $createdAt > $expireSeconds) error('Web登录Token已过期');
$playerName = $tokenRow['player_name'];
// 检查是否已注册
if (isPlayerRegistered($playerName)) {
error('玩家已注册: ' . $playerName);
}
// 生成密码凭证(与插件相同的SHA-256算法)
$saltBytes = random_bytes(16);
$salt = base64_encode($saltBytes);
$passwordHash = base64_encode(hash('sha256', $saltBytes . $password, true));
// ★ 创建web_register_requests表记录(等待插件同步)
$db->exec("CREATE TABLE IF NOT EXISTS web_register_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
email TEXT DEFAULT '',
ip_address TEXT DEFAULT '',
created_at INTEGER NOT NULL,
status TEXT DEFAULT 'pending',
processed_at INTEGER DEFAULT 0
)");
// 插入注册请求
$stmt = $db->prepare("INSERT INTO web_register_requests (player_name, password_hash, salt, email, ip_address, created_at) VALUES (:player, :hash, :salt, :email, :ip, :time)");
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
$stmt->bindValue(':hash', $passwordHash, SQLITE3_TEXT);
$stmt->bindValue(':salt', $salt, SQLITE3_TEXT);
$stmt->bindValue(':email', $email, SQLITE3_TEXT);
$stmt->bindValue(':ip', $ipAddress, SQLITE3_TEXT);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
$requestId = $db->lastInsertRowID();
success([
'player' => $playerName,
'request_id' => $requestId,
'email' => $email
], '注册请求已提交,等待游戏同步');
}
// ===== 查询账号信息 =====
function registerQuery($token) {
$player = getParam('player');
if (!$player) error('缺少player');
$db = getDB();
// 只查询users表实际存在的列
$stmt = $db->prepare("SELECT player_name, register_time, last_login_time, email, points, gift_stage, total_online_time FROM users WHERE player_name = :name");
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if (!$row) {
error('账号不存在: ' . $player);
}
// 处理Java插件推送的毫秒级时间戳,转换为秒级
if ($row['register_time'] && $row['register_time'] > 1000000000000) {
$row['register_time'] = (int)($row['register_time'] / 1000);
}
if ($row['last_login_time'] && $row['last_login_time'] > 1000000000000) {
$row['last_login_time'] = (int)($row['last_login_time'] / 1000);
}
// ★ 获取债券余额
$bonds = 0;
try {
$bondStmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
$bondStmt->bindValue(':name', $player, SQLITE3_TEXT);
$bondResult = $bondStmt->execute();
$bondRow = $bondResult->fetchArray(SQLITE3_ASSOC);
if ($bondRow) $bonds = (int)($bondRow['amount'] ?? 0);
} catch (\Throwable $e) {}
// ★ 获取累计在线天数(不重复日期计数)
$totalOnlineDays = 0;
try {
$odStmt = $db->prepare("SELECT COUNT(DISTINCT login_date) as cnt FROM player_daily_logins WHERE player_name = :name");
$odStmt->bindValue(':name', $player, SQLITE3_TEXT);
$odResult = $odStmt->execute();
$odRow = $odResult->fetchArray(SQLITE3_ASSOC);
if ($odRow) $totalOnlineDays = (int)($odRow['cnt'] ?? 0);
} catch (\Throwable $e) {}
// ★ 获取累计签到天数
$totalCheckinDays = 0;
try {
$ciStmt = $db->prepare("SELECT COUNT(DISTINCT checkin_date) as cnt FROM player_checkins WHERE player_name = :name");
$ciStmt->bindValue(':name', $player, SQLITE3_TEXT);
$ciResult = $ciStmt->execute();
$ciRow = $ciResult->fetchArray(SQLITE3_ASSOC);
if ($ciRow) $totalCheckinDays = (int)($ciRow['cnt'] ?? 0);
} catch (\Throwable $e) {}
// 合并额外数据
$row['bonds'] = $bonds;
$row['total_online_days'] = $totalOnlineDays;
$row['total_checkin_days'] = $totalCheckinDays;
// 邮箱脱敏
if (!empty($row['email'])) {
$parts = explode('@', $row['email']);
if (count($parts) === 2) {
$row['masked_email'] = str_repeat('*', min(strlen($parts[0]), 3)) . '@' . $parts[1];
} else {
$row['masked_email'] = '***';
}
} else {
$row['masked_email'] = '';
}
// ★ 严格校验:必须有有效token且token对应的玩家必须与查询的玩家匹配(除非是管理员token)
if (!$token) {
// 无token:隐藏敏感信息
$previewRow = [
'player_name' => $row['player_name'],
'register_time' => $row['register_time'],
'last_login_time' => $row['last_login_time'],
'email' => '',
'masked_email' => '',
'points' => 0,
'gift_stage' => 0,
'total_online_time' => 0,
'bonds' => 0,
'total_online_days' => 0,
'total_checkin_days' => 0
];
preview($previewRow, '预览模式 - 部分信息已隐藏');
return;
}
$tokenInfo = validateTokenSilent($token);
if (!$tokenInfo) {
error('无效token,请先登录', 401);
}
$tokenPurpose = $tokenInfo['purpose'] ?? '';
if ($tokenPurpose === 'admin' || $tokenPurpose === 'all') {
// 管理员token:返回完整信息
success($row);
} elseif ($tokenInfo['player'] === $player) {
// 普通token:只能查自己的完整信息
success($row);
} else {
// 不是自己的账号,返回脱敏信息
$maskedRow = [
'player_name' => $row['player_name'],
'register_time' => $row['register_time'],
'last_login_time' => $row['last_login_time'],
'email' => '',
'masked_email' => '***',
'points' => 0,
'gift_stage' => 0,
'total_online_time' => 0,
'bonds' => 0,
'total_online_days' => 0,
'total_checkin_days' => 0
];
preview($maskedRow, '预览模式 - 您只能查看自己的账号信息');
}
}
-94
View File
@@ -1,94 +0,0 @@
<?php
/**
* 异地登录检测回调(Java 插件在玩家加入服务器时异步调用)
*
* 调用方式:GET api/security_alert.php
* ?action=login_location_alert&secret=...&name=<玩家>&ip=<登录IP>
*
* 职责:
* 1. 用 ip9.com.cn 查询本次登录 IP 归属
* 2. 与上次登录归属比较,不一致且非首次 → 向绑定邮箱发送提醒邮件
* 3. 邮件内含「冻结账号」与「修改密码」两个链接
* 4. 更新 users.last_login_location / last_login_ip
*/
require_once __DIR__ . '/../core.php';
header('Content-Type: application/json; charset=utf-8');
$action = $_GET['action'] ?? '';
if ($action !== 'login_location_alert') {
echo json_encode(['success' => false, 'message' => '未知操作']);
exit;
}
$secret = $_GET['secret'] ?? '';
if (!$secret || $secret !== SECRET_KEY) {
echo json_encode(['success' => false, 'message' => '密钥错误']);
exit;
}
$name = $_GET['name'] ?? '';
$ip = $_GET['ip'] ?? '';
if (!$name || !$ip) {
echo json_encode(['success' => false, 'message' => '参数缺失']);
exit;
}
$db = getDB();
// 查询账号信息
$stmt = $db->prepare(
"SELECT email, last_login_location, last_login_ip, frozen
FROM users WHERE player_name = :name");
$stmt->bindValue(':name', $name, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
// 查询本次 IP 归属
$location = queryLoginIpLocation($ip);
if ($location === null) {
// 归属查询失败:仅更新 IP,不阻断、不提醒
$upd = $db->prepare(
"UPDATE users SET last_login_ip = :ip WHERE player_name = :name");
$upd->bindValue(':ip', $ip, SQLITE3_TEXT);
$upd->bindValue(':name', $name, SQLITE3_TEXT);
$upd->execute();
echo json_encode(['success' => true, 'message' => 'IP归属查询失败,仅更新IP']);
exit;
}
if (!$row) {
// 理论上 Java 已 syncUserOnJoin,不会走到这里
echo json_encode(['success' => true, 'message' => '无用户记录']);
exit;
}
$lastLoc = $row['last_login_location'] ?? '';
$lastIp = $row['last_login_ip'] ?? '';
$email = $row['email'] ?? '';
// 更新归属与 IP
$upd = $db->prepare(
"UPDATE users SET last_login_ip = :ip,
last_login_location = :loc WHERE player_name = :name");
$upd->bindValue(':ip', $ip, SQLITE3_TEXT);
$upd->bindValue(':loc', $location, SQLITE3_TEXT);
$upd->bindValue(':name', $name, SQLITE3_TEXT);
$upd->execute();
// 首次登录(无任何历史记录)不提醒
if ($lastLoc === '' && $lastIp === '') {
echo json_encode(['success' => true, 'message' => '首次登录,已记录']);
exit;
}
// 归属变化才发送提醒邮件
if ($lastLoc !== $location && !empty($email)) {
// 生成冻结 token 并持久化,供邮件中的冻结链接使用
$freezeToken = bin2hex(random_bytes(16));
$ut = $db->prepare("UPDATE users SET freeze_token = :t WHERE player_name = :name");
$ut->bindValue(':t', $freezeToken, SQLITE3_TEXT);
$ut->bindValue(':name', $name, SQLITE3_TEXT);
$ut->execute();
sendLocationAlertEmail($name, $email, $ip, $location, $lastLoc, $freezeToken);
}
echo json_encode(['success' => true, 'message' => 'ok']);
-711
View File
@@ -1,711 +0,0 @@
<?php
/**
* 商城API
* GET ?action=list - 商品列表(无需token,公开接口)
* POST ?action=buy - 购买商品(需token)
* GET ?action=categories - 分类列表(无需token,公开接口)
*/
// 防止任何输出污染JSON响应
while (ob_get_level() > 0) { ob_end_clean(); }
require_once __DIR__ . '/../core.php';
// 再次确保清理前置输出
while (ob_get_level() > 0) { ob_end_clean(); }
$action = getParam('action', 'list');
$token = getParam('token');
try {
switch ($action) {
case 'list':
shopList($token);
break;
case 'buy':
shopBuy($token);
break;
case 'buy_cart':
shopBuyCart($token);
break;
case 'cart_config':
cartConfig();
break;
case 'categories':
shopCategories($token);
break;
default:
error('未知操作: ' . $action);
}
// ★ WAL checkpoint:释放WAL锁,减少database is locked概率
try { walCheckpoint(); } catch (\Throwable $ignored) {}
} catch (\Throwable $e) {
// ★ 强制回滚可能残留的事务(防止database is locked连锁故障)
try { $db = getDB(); $db->exec('ROLLBACK'); } catch (\Throwable $_) {}
while (ob_get_level() > 0) ob_end_clean();
error('服务器内部错误: ' . $e->getMessage(), 500);
}
// ===== 商品列表(公开,无需token) =====
function shopList($token = null) {
try {
$db = getDB();
$category = getParam('category');
if ($category) {
$stmt = $db->prepare("SELECT * FROM shop_items WHERE category = :cat ORDER BY id");
$stmt->bindValue(':cat', $category, SQLITE3_TEXT);
$result = $stmt->execute();
} else {
$result = $db->query("SELECT * FROM shop_items ORDER BY category, id");
}
$items = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$items[] = $row;
}
// 直接返回数据,不验证token(公开接口)
$isPreview = !$token;
if ($token && validateTokenSilent($token)) {
$isPreview = false;
}
if ($isPreview) {
preview($items, '预览模式 - 无有效token');
} else {
success($items);
}
} catch (Exception $e) {
error('商城加载失败: ' . $e->getMessage(), 500);
}
}
// validateTokenSilent() 已在 core.php 中定义,不再重复声明
// ===== 分类列表 =====
function shopCategories() {
$db = getDB();
$result = $db->query("SELECT DISTINCT category, COUNT(*) as item_count FROM shop_items GROUP BY category");
$cats = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$cats[] = $row;
}
success($cats);
}
// ===== 购买商品 =====
function shopBuy($token) {
$itemId = getParam('item_id');
$amount = (int)getParam('amount', 1);
$player = getParam('player');
$password = getParam('password');
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!$itemId) error('缺少item_id');
if (!$player) error('缺少player');
if ($amount < 1) error('数量无效');
// ★ 双保险验证:购买操作需要密码确认
$isPreview = true;
$isOnline = false;
$isRegistered = false;
if ($token) {
// 先检查是否为普通管理token
$tokenInfo = validateToken($token);
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
$isPreview = false;
if (validateToken($token)) {
validateAndUseToken($token);
}
} else {
// weblogin token → 走双保险验证
$accessResult = validateWebAccess($token, 'buy', $password, $ipAddress);
if ($accessResult['ok']) {
$isPreview = false;
$player = $accessResult['player'];
$isOnline = $accessResult['online'] ?? false;
$isRegistered = $accessResult['registered'] ?? false;
} elseif ($accessResult['mode'] === 'need_password') {
jsonResponse([
'success' => false,
'need_password' => true,
'player' => $accessResult['player'],
'message' => $accessResult['message']
], 401);
} elseif ($accessResult['mode'] === 'need_game_login') {
jsonResponse([
'success' => false,
'need_game_login' => true,
'player' => $accessResult['player'],
'message' => $accessResult['message']
], 401);
} elseif ($accessResult['mode'] === 'need_register') {
jsonResponse([
'success' => false,
'need_register' => true,
'player' => $accessResult['player'],
'message' => $accessResult['message']
], 401);
} else {
error($accessResult['message'], 401);
}
}
}
// 查询商品
$db = getDB();
$stmt = $db->prepare("SELECT * FROM shop_items WHERE id = :id");
$stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
$result = $stmt->execute();
$item = $result->fetchArray(SQLITE3_ASSOC);
if (!$item) error('商品不存在: ' . $itemId);
// 检查库存
if ($item['stock'] == 0) error('商品已售罄');
if ($item['stock'] > 0 && $item['stock'] < $amount) error('库存不足');
$totalPrice = $item['buy_price'] * $amount;
// 检查玩家余额
if (!$isPreview && $totalPrice > 0) {
$balanceStmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :player");
$balanceStmt->bindValue(':player', $player, SQLITE3_TEXT);
$balanceResult = $balanceStmt->execute();
$balanceRow = $balanceResult->fetchArray(SQLITE3_ASSOC);
if (!$balanceRow) {
error('玩家余额查询失败');
}
$balance = (int)$balanceRow['amount'];
if ($balance < $totalPrice) {
error('余额不足,当前余额: ' . $balance . ' 债券,需要: ' . $totalPrice . ' 债券');
}
}
if ($isPreview) {
// 预览模式:返回计算结果但不执行
preview([
'item_id' => $itemId,
'item_name' => $item['display_name'],
'amount' => $amount,
'unit_price' => $item['buy_price'],
'total_price' => $totalPrice,
'stock_before' => $item['stock'],
'stock_after' => $item['stock'] > 0 ? $item['stock'] - $amount : -1,
'player' => $player
], '预览模式 - 购买不会生效');
}
// ★ 实际购买 — 包装在事务中,防止database is locked
$db->exec('BEGIN IMMEDIATE');
try {
// 更新库存(同时写admin_stock确保Java同步)
if ($item['stock'] > 0) {
$newStock = $item['stock'] - $amount;
$stmt = $db->prepare("UPDATE shop_items SET stock = :ns, admin_stock = :ns, last_modified = :t, hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
$stmt->bindValue(':ns', $newStock, SQLITE3_INTEGER);
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
$stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
$stmt->execute();
} else {
// 无限库存,只更新销量
$stmt = $db->prepare("UPDATE shop_items SET hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
$stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
$stmt->execute();
}
// 记录交易
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'shop_buy', :amount, :reason, :detail, 'pending', :time)");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':amount', $totalPrice, SQLITE3_INTEGER);
$stmt->bindValue(':reason', "购买: {$item['display_name']} x{$amount}", SQLITE3_TEXT);
$stmt->bindValue(':detail', json_encode([
'item_id' => $itemId,
'amount' => $amount,
'unit_price' => $item['buy_price'],
'tx_id' => bin2hex(random_bytes(8))
], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
// ★ 写入sync_requests(必须在success/exit之前!)
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
$stmt2 = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
$stmt2->bindValue(':player', $player, SQLITE3_TEXT);
$stmt2->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt2->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
try { $db->exec('ROLLBACK'); } catch (Exception $e2) {}
error('购买失败: ' . $e->getMessage(), 500);
}
success([
'item_id' => $itemId,
'item_name' => $item['display_name'],
'amount' => $amount,
'total_price' => $totalPrice,
'player' => $player
], '购买成功');
}
// ===== 购物车配置读取(批量优化:单次查询读取所有需要的配置) =====
function getShopConfig($key, $default = null) {
try {
$db = getDB();
$stmt = $db->prepare("SELECT cfg_value FROM shop_config WHERE cfg_key = :k");
$stmt->bindValue(':k', $key, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
return $row ? $row['cfg_value'] : $default;
} catch (Exception $e) {
return $default;
}
}
/**
* 批量读取多个shop_config键(一次查询替代N次getShopConfig调用)
* @return array key => value (不存在的key不返回)
*/
function getShopConfigs(array $keys) {
if (empty($keys)) return [];
try {
$db = getDB();
$placeholders = implode(',', array_fill(0, count($keys), '?'));
$stmt = $db->prepare("SELECT cfg_key, cfg_value FROM shop_config WHERE cfg_key IN ($placeholders)");
foreach ($keys as $i => $k) {
$stmt->bindValue($i + 1, $k, SQLITE3_TEXT);
}
$result = $stmt->execute();
$map = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$map[$row['cfg_key']] = $row['cfg_value'];
}
return $map;
} catch (Exception $e) {
return [];
}
}
// 返回购物车折扣/加价配置(前端展示用,★ 批量读取)
function cartConfig() {
$cfg = getShopConfigs(['cart_backpack_rate', 'cart_shulker_rate', 'packmoney', 'green_discount']);
success([
'backpack_rate' => (float)($cfg['cart_backpack_rate'] ?? '0.98'),
'shulker_rate' => (float)($cfg['cart_shulker_rate'] ?? '1.00'),
'packmoney' => (float)($cfg['packmoney'] ?? '5'),
'green_discount' => (float)($cfg['green_discount'] ?? '10')
]);
}
// ===== 购物车结算(多商品一次性购买) =====
function shopBuyCart($token) {
$__t0 = microtime(true);
debugLog('[buy_cart] 进入请求');
$rawItems = getParam('items');
$settlement = getParam('settlement', 'backpack'); // backpack=塞背包, shulker=潜影盒打包
$shulkerColor = getParam('shulker_color', 'default'); // 潜影盒颜色(default原色免费,其它+2元)
$payMode = getParam('pay_mode', 'bond'); // bond=债券扣款, cash=现金仅记账
$player = getParam('player');
$password = getParam('password');
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (!$rawItems) error('购物车为空');
$items = json_decode($rawItems, true);
if (!is_array($items) || count($items) === 0) error('购物车格式错误');
// 校验每一项
$parsed = [];
foreach ($items as $it) {
if (!isset($it['item_id']) || !isset($it['amount'])) error('商品项缺少字段');
$pid = (string)$it['item_id'];
$amt = (int)$it['amount'];
if ($amt < 1) error('数量无效: ' . $pid);
$parsed[] = ['item_id' => $pid, 'amount' => $amt];
}
if (!$player) error('缺少player');
// ★ 双保险验证(与单件购买一致)
$isPreview = true;
$isAdminToken = false;
$isOnline = false;
$isRegistered = false;
$isCashier = false;
$isAdminSession = false;
$operatorDiscountLimit = 0;
$cashierRow = null;
$orderNo = '';
if ($token) {
$tokenInfo = validateToken($token);
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
$isPreview = false;
$isAdminToken = true; // ★ 记录代购令牌身份,避免下方大额复检重复校验已消费的token导致误判
if (validateToken($token)) validateAndUseToken($token);
} else {
$accessResult = validateWebAccess($token, 'buy', $password, $ipAddress);
if ($accessResult['ok']) {
$isPreview = false;
$player = $accessResult['player'];
$isOnline = $accessResult['online'] ?? false;
$isRegistered = $accessResult['registered'] ?? false;
} elseif (($accessResult['mode'] ?? '') === 'need_password') {
jsonResponse(['success' => false, 'need_password' => true, 'player' => $accessResult['player'], 'message' => $accessResult['message']], 401);
} elseif (($accessResult['mode'] ?? '') === 'need_game_login') {
jsonResponse(['success' => false, 'need_game_login' => true, 'player' => $accessResult['player'], 'message' => $accessResult['message']], 401);
} elseif (($accessResult['mode'] ?? '') === 'need_register') {
jsonResponse(['success' => false, 'need_register' => true, 'player' => $accessResult['player'], 'message' => $accessResult['message']], 401);
} else {
error($accessResult['message'], 401);
}
}
} else {
// 收银员/管理员会话代购通路(独立收银台,无需token)
if (isCashierLoggedIn()) {
$isPreview = false;
$isCashier = true;
$cashierRow = getCurrentCashier();
$operatorDiscountLimit = (int)($cashierRow['discount_limit_percent'] ?? 0);
} elseif (isAdminLoggedIn()) {
$isPreview = false;
$isAdminSession = true;
}
}
// 结算模式对应的折扣/加价系数(★ 批量读取所有配置键,1次查询替代4次)
$cfg = getShopConfigs(['cart_shulker_rate', 'packmoney', 'cart_backpack_rate', 'green_discount']);
if ($settlement === 'shulker') {
$rate = (float)($cfg['cart_shulker_rate'] ?? '1.00');
$modeName = '潜影盒打包';
// 潜影盒颜色额外收费(原色/紫色免费,其它加收打包费,打包费来自配置 packmoney)
$packMoney = (float)($cfg['packmoney'] ?? '5');
$colorFee = ($shulkerColor !== 'default' && $shulkerColor !== 'purple') ? $packMoney : 0;
// 颜色名称映射
$colorNames = ['default'=>'原色','purple'=>'原色','white'=>'白色','black'=>'黑色','red'=>'红色','blue'=>'蓝色','green'=>'绿色','yellow'=>'黄色','orange'=>'橙色'];
$colorName = $colorNames[$shulkerColor] ?? $shulkerColor;
$ecoPct = 0;
} else {
$settlement = 'backpack';
$rawBackpackRate = (float)($cfg['cart_backpack_rate'] ?? '0.98');
// ★ 修复(2026-07-08):背包(环保单)模式必须尊重游戏内 green_discount 设置。
// green_discount 语义:10=不打折,9=9折(×0.9),8.5=8.5折(×0.85)。
// 游戏内设置了打折(green_discount < 10)时,以游戏折扣为准(如 setgreen 9 → ×0.9),
// 覆盖默认的 cart_backpack_rate(0.98=98折);未设置(=10)时仍走 cart_backpack_rate 保持原有环保优惠。
$greenDiscount = (float)($cfg['green_discount'] ?? 10);
$rate = ($greenDiscount < 10) ? ($greenDiscount / 10) : $rawBackpackRate;
$modeName = '塞背包(环保单)';
$colorFee = 0;
$colorName = '';
$ecoPct = 0;
}
$db = getDB();
$lines = [];
$subtotal = 0;
foreach ($parsed as &$p) {
$stmt = $db->prepare("SELECT * FROM shop_items WHERE id = :id");
$stmt->bindValue(':id', $p['item_id'], SQLITE3_TEXT);
$result = $stmt->execute();
$item = $result->fetchArray(SQLITE3_ASSOC);
if (!$item) error('商品不存在: ' . $p['item_id']);
if ($item['stock'] == 0) error('商品已售罄: ' . $item['display_name']);
if ($item['stock'] > 0 && $item['stock'] < $p['amount']) error('库存不足: ' . $item['display_name'] . '(剩余' . $item['stock'] . ')');
// ★ 发货前验证:检查商品material是否为Java端可识别的有效值
// 防止扣款后发现Java无法匹配该材料(如附魔书ID被误写为material字段)导致钱扣了货没给
$mat = strtoupper(trim($item['material']));
$validMaterial = (
// 标准Bukkit Material:大写字母+数字+下划线,如 DIAMOND_SWORD, ENCHANTED_BOOK
preg_match('/^[A-Z][A-Z0-9_]+$/', $mat) &&
// 排除明显不是合法Material的值(如附魔书ID MENDING_I 等——这些应写在id而非material列)
// ENCHANTED_BOOK 是唯一合法的附魔书Material
($mat === 'ENCHANTED_BOOK' || !preg_match('/_(I{1,3}|II|III|IV|V|[1-5])$/', $mat))
);
// 允许 id 字段是附魔书格式(如 MENDING_I),此时 material 必须是 ENCHANTED_BOOK
if (!$validMaterial) {
$pid = strtoupper(trim($p['item_id']));
$isEnchantedId = preg_match('/^[A-Z][A-Z0-9]*_[IVX12]+$/', $pid);
if (!($isEnchantedId && $mat === 'ENCHANTED_BOOK') && $mat !== 'ENCHANTED_BOOK') {
error('商品无法发放(material无效): ' . $item['display_name']
. ' [material=' . $item['material'] . ', id=' . $p['item_id'] . ']'
. ' — 请管理员检查该商品的material字段是否正确');
}
}
$lineTotal = (int)$item['buy_price'] * $p['amount'];
$subtotal += $lineTotal;
$p['item'] = $item;
$lines[] = [
'item_id' => $p['item_id'],
'name' => $item['display_name'],
'unit_price' => (int)$item['buy_price'],
'amount' => $p['amount'],
'line_total' => $lineTotal
];
}
unset($p);
// 先按费率计算,再叠加环保单折扣(不打包/塞背包,折扣率为折数),最后加颜色打包费
// green_discount 语义:10=不打折,9.9=9.9折(×0.99),9.8=9.8折(×0.98)
$baseAfterRate = (int)round($subtotal * $rate);
if ($ecoPct > 0) $baseAfterRate = (int)round($baseAfterRate * $ecoPct / 10);
$totalPrice = $baseAfterRate + $colorFee;
$saved = $subtotal - $totalPrice; // 折扣省下的(潜影盒加价+颜色费时为负)
// ===== 收银员/管理员手动折扣(服务端强制上限,前端声称不可信)=====
$discountPercent = 0;
$discountAmount = 0;
$finalPrice = $totalPrice;
$canDiscount = ($isCashier || $isAdminToken || $isAdminSession);
if ($canDiscount) {
$reqDiscount = (float)getParam('discount_percent', 0);
if ($reqDiscount < 0) $reqDiscount = 0;
if ($reqDiscount > 100) $reqDiscount = 100;
// 收银员受自身折扣上限限制;管理员无限制(最大100)
$limit = $isCashier ? $operatorDiscountLimit : 100;
if ($reqDiscount > $limit) {
error('折扣超过权限上限(收银员最大可打' . $limit . '% off)', 403);
}
$discountPercent = $reqDiscount;
if ($discountPercent > 0) {
$discountAmount = (int)round($totalPrice * $discountPercent / 100);
$finalPrice = $totalPrice - $discountAmount;
}
}
// ★ 安全检查:服务端计算的实际扣款额≥1000时,必须已通过密码验证(防止前端绕过)
// 管理员代购令牌(admin/all)已在上方标记$isAdminToken并消费,此处不再重新校验token,
// 而是校验代购操作者(管理员)登录密码;普通玩家通路走validateWebAccess已在上方完成验证;
// 收银员通路已通过收银员会话登录,视为已授权,无需额外密码
if ($finalPrice >= 1000 && !$isPreview) {
if ($isCashier) {
// 收银员已登录即授权,无需额外密码
} elseif ($isAdminToken || $isAdminSession) {
// 代购通路:校验管理员登录密码(代购操作者的密码),而非目标玩家游戏密码
// ★ 首次部署:ADMIN_PASS 还是占位符 = 没配置密码,此时任何"管理员密码"
// 都无从校验 → 一律按未通过处理(引导页会要求先设置密码)。
$passReady = function_exists('secAdminPassConfigured')
? secAdminPassConfigured()
: (defined('ADMIN_PASS') && trim((string)ADMIN_PASS) !== ''
&& trim((string)ADMIN_PASS) !== 'REPLACE_ME_ADMIN_PASSWORD');
if (!$password || trim($password) === '' || !$passReady || $password !== ADMIN_PASS) {
jsonResponse(['success' => false, 'need_password' => true,
'player' => $player, 'message' => '金额≥1000债券需确认管理员密码'], 401);
}
} elseif (!isset($accessResult['ok'])) {
// 非管理员且未通过密码验证 → 拒绝
error('大额交易需验证游戏登录密码', 401);
}
}
// ★ 现金收款权限校验:收银员需管理员授予 can_cash 权限
if ($payMode === 'cash') {
if ($isCashier && empty($cashierRow['can_cash'])) {
error('该收银员无现金收款权限(需管理员在收银员管理中开启)', 403);
}
// 现金模式:仅记账,不扣玩家债券(跳过余额校验与扣款)
}
// 检查余额(现金模式跳过:仅记账)
if (!$isPreview && $finalPrice > 0 && $payMode !== 'cash') {
$balanceStmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :player");
$balanceStmt->bindValue(':player', $player, SQLITE3_TEXT);
$balanceResult = $balanceStmt->execute();
$balanceRow = $balanceResult->fetchArray(SQLITE3_ASSOC);
if (!$balanceRow) error('玩家余额查询失败');
$balance = (int)$balanceRow['amount'];
if ($balance < $finalPrice) {
error('余额不足,当前余额: ' . $balance . ' 债券,需要: ' . $finalPrice . ' 债券');
}
}
if ($isPreview) {
preview([
'settlement' => $settlement,
'mode_name' => $modeName,
'rate' => $rate,
'items' => $lines,
'subtotal' => $subtotal,
'total_price' => $totalPrice,
'saved' => $saved,
'player' => $player
], '预览模式 - 结算不会生效');
}
// ★ 实际购买 — 包装在事务中
$db->exec('BEGIN IMMEDIATE');
try {
foreach ($parsed as $p) {
$item = $p['item'];
if ($item['stock'] > 0) {
$newStock = $item['stock'] - $p['amount'];
$stmt = $db->prepare("UPDATE shop_items SET stock = :ns, admin_stock = :ns, last_modified = :t, hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
$stmt->bindValue(':ns', $newStock, SQLITE3_INTEGER);
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
$stmt->bindValue(':amount', $p['amount'], SQLITE3_INTEGER);
$stmt->bindValue(':id', $p['item_id'], SQLITE3_TEXT);
$stmt->execute();
} else {
$stmt = $db->prepare("UPDATE shop_items SET hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
$stmt->bindValue(':amount', $p['amount'], SQLITE3_INTEGER);
$stmt->bindValue(':id', $p['item_id'], SQLITE3_TEXT);
$stmt->execute();
}
}
// 记录一条合并交易
$reasonItems = array_map(function ($l) { return $l['name'] . ' x' . $l['amount']; }, $lines);
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'shop_cart', :amount, :reason, :detail, 'pending', :time)");
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':amount', $finalPrice, SQLITE3_INTEGER);
$stmt->bindValue(':reason', "购物车结算({$modeName}" . ($colorName ? "-{$colorName}潜影盒" : '') . "): " . implode('、', $reasonItems), SQLITE3_TEXT);
$detailData = [
'settlement' => $settlement,
'rate' => $rate,
'subtotal' => $subtotal,
'total_price' => $finalPrice,
'discount_percent' => (int)$discountPercent,
'discount_amount' => (int)$discountAmount,
'pay_mode' => $payMode,
'items' => $lines,
'tx_id' => bin2hex(random_bytes(8))
];
if ($settlement === 'shulker') {
$detailData['shulker_color'] = $shulkerColor;
$detailData['color_name'] = $colorName;
$detailData['color_fee'] = $colorFee;
}
$stmt->bindValue(':detail', json_encode($detailData, JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
// 写入sync_requests
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
$stmt2 = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
$stmt2->bindValue(':player', $player, SQLITE3_TEXT);
$stmt2->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt2->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
try { $db->exec('ROLLBACK'); } catch (Exception $e2) {}
error('购物车结算失败: ' . $e->getMessage(), 500);
}
// ★ 购买主流程提交成功后,再记录收银台订单到独立 orders.db。
// 与 web.db 事务解耦:订单写失败仅影响日志/小票,绝不回滚已成功的库存与发药。
$orderNo = '';
if ($isCashier || $isAdminToken || $isAdminSession) {
$operatorType = $isCashier ? 'cashier' : 'admin';
$operatorName = $isCashier ? ($cashierRow['username'] ?? '') : 'admin';
try {
$orderNo = recordCashierOrder([
'operator_type' => $operatorType,
'operator_name' => $operatorName,
'player_name' => $player,
'items_detail' => $lines,
'subtotal' => $subtotal,
'total_price' => $finalPrice,
'discount_percent' => (int)$discountPercent,
'discount_amount' => (int)$discountAmount,
'settlement' => $settlement,
'payment_method' => $payMode
]);
} catch (\Throwable $e) {
$orderNo = '';
debugLog('buy_cart: 订单记录失败(不影响购买): ' . $e->getMessage());
}
}
// ★ 推送小票书给在线玩家(写入 web_admin_changes → Java pollAdminChanges 拾取并发放 Written Book)
// 注意:小票推送失败绝不影响已成功的购买主流程与订单记录,单独容错。
if (!empty($orderNo) && !empty($player)) {
try {
pushReceiptBookToPlayer($player, [
'order_no' => $orderNo,
'order_time' => date('Y-m-d H:i:s'),
'order_player' => $player,
'operator' => ($isCashier ? ($cashierRow['username'] ?? '') : 'admin'),
'settlement' => $settlement,
'pay_method' => $payMode,
'total_price' => (int)$finalPrice,
'items_text' => array_map(function($l) { return $l['name'] . ' x' . $l['amount'] . ' ... ' . $l['line_total'] . '债'; }, $lines)
]);
} catch (\Throwable $e) {
debugLog('buy_cart: 小票书推送失败(不影响购买): ' . $e->getMessage());
}
}
debugLog('[buy_cart] 完成, 耗时=' . round((microtime(true) - $__t0) * 1000) . 'ms, orderNo=' . ($orderNo ?? ''));
success([
'settlement' => $settlement,
'mode_name' => $modeName,
'rate' => $rate,
'items' => $lines,
'subtotal' => $subtotal,
'total_price' => $finalPrice,
'original_price' => $totalPrice,
'discount_percent' => (int)$discountPercent,
'discount_amount' => (int)$discountAmount,
'saved' => $saved,
'player' => $player,
'order_no' => $orderNo ?? ''
], '购物车结算成功');
}
// ===== Weblogin Token验证(用于商城购买)=====
function validateWebloginTokenForShop($webToken) {
$db = getDB();
try {
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE web_token = :token");
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if (!$row) return false;
$createdAt = (int)$row['created_at'];
$expireSeconds = (int)$row['expire_seconds'];
if (time() - $createdAt > $expireSeconds) return false;
return [
'player' => $row['player_name'],
'purpose' => 'weblogin',
'created_at' => $createdAt,
'expires_at' => $createdAt + $expireSeconds
];
} catch (Exception $e) {
return false;
}
}
// ===== 推送打包小票书给在线玩家(写入 web_admin_changes → Java 拾取发放 Written Book)=====
function pushReceiptBookToPlayer($playerName, $orderData) {
try {
$db = getDB();
// 确保表存在
$db->exec("CREATE TABLE IF NOT EXISTS web_admin_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
change_type TEXT NOT NULL,
target_id TEXT NOT NULL,
target_name TEXT DEFAULT '',
change_data TEXT DEFAULT '{}',
created_at INTEGER DEFAULT 0,
acknowledged INTEGER DEFAULT 0,
acked_at INTEGER DEFAULT 0,
status TEXT DEFAULT 'done'
)");
$stmt = $db->prepare("INSERT INTO web_admin_changes (change_type, target_id, target_name, change_data, created_at, status)
VALUES ('give_receipt_book', '0', :player, :data, :time, 'pending')");
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
$stmt->bindValue(':data', json_encode($orderData, JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
$stmt->execute();
} catch (Exception $e) {
error_log('[小票书] 推送小票书失败: ' . $e->getMessage());
}
}
-1388
View File
File diff suppressed because it is too large Load Diff
-5952
View File
File diff suppressed because it is too large Load Diff
-982
View File
@@ -1,982 +0,0 @@
<?php
/**
* 工单系统API
*
* 用户端:提交工单、查看我的工单、查看详情、回复(MD)、撤销
* 管理员端:查看所有工单、回复、驳回、分配服务商
* 服务商端:抢单大厅、我处理的工单、回复、标记完结
*/
while (ob_get_level() > 0) { @ob_end_clean(); }
ob_start();
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store, no-cache, must-revalidate');
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
// 仅刷新当前文件的OPcache(不要opcache_reset——会重置整个服务器缓存导致竞态条件)
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
register_shutdown_function(function() {
while (ob_get_level() > 0) { @ob_end_flush(); }
});
require_once __DIR__ . '/../core.php';
// 工单附件图:兰空图床客户端(本文件不含敏感值,凭据在 lsky_keys.php)
require_once __DIR__ . '/../lsky.php';
if (session_status() === PHP_SESSION_NONE) { session_start(); }
$action = getParam('action', '');
try {
switch ($action) {
// ===== 用户端 =====
case 'create': ticketCreate(); break;
case 'my_list': ticketMyList(); break;
case 'detail': ticketDetail(); break;
case 'reply': ticketReply(); break;
case 'withdraw': ticketWithdraw(); break;
// ===== 管理员端 =====
case 'list_all': ticketListAll(); break;
case 'admin_reply': ticketAdminReply(); break;
case 'reject': ticketReject(); break;
case 'assign': ticketAssign(); break;
case 'admin_create': ticketAdminCreate(); break;
case 'admin_complete': ticketAdminComplete(); break;
// ===== 服务商端 =====
case 'provider_list': ticketProviderList(); break;
case 'available': ticketAvailable(); break;
case 'grab': ticketGrab(); break;
case 'provider_reply': ticketProviderReply(); break;
case 'complete': ticketProviderComplete(); break;
// ===== 通用 =====
case 'upload_image': ticketUploadImage(); break;
case 'providers': ticketProviders(); break;
case 'provider_check': ticketProviderCheck(); break;
default:
error('未知操作: ' . $action);
}
// ★ WAL checkpoint:释放WAL锁,减少database is locked概率
try { walCheckpoint(); } catch (\Throwable $ignored) {}
} catch (\Throwable $e) {
error('服务器错误: ' . $e->getMessage());
}
// ============================================================
// 工具函数
// ============================================================
function requireWebToken() {
$token = getParam('token');
if (!$token) error('缺少token', 401);
$info = validateTokenSilent($token);
if (!$info) error('token无效或已过期', 401);
return $info;
}
function requireAdmin() {
if (!isAdminLoggedIn()) error('未登录管理后台', 401);
}
/**
* 检查是否为管理员或授权的服务商用户
* 管理员通过session认证,用户通过token认证
*/
function getAuthUser() {
if (isAdminLoggedIn()) return ['player' => 'admin', 'role' => 'admin'];
return requireWebToken();
}
function isServiceProviderInGame($playerName) {
// 优先从Web.db的web_service_providers表读取(Java同步数据)
$db = getDB();
try {
$stmt = $db->prepare("SELECT 1 FROM web_service_providers WHERE player_name = :p AND active = 1");
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray();
if ($row !== false) return true;
} catch (\Throwable $e) { /* 表可能不存在 */ }
// 回退:尝试读取Java的login.db(本地开发环境)
$loginDbPath = GAME_LOGIN_DB;
if (!file_exists($loginDbPath)) return false;
try {
$loginDb = new SQLite3($loginDbPath);
$loginDb->enableExceptions(true);
$stmt = $loginDb->prepare("SELECT 1 FROM service_providers WHERE player_name = :p AND active = 1");
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray();
$loginDb->close();
return $row !== false;
} catch (Exception $e) {
return false;
}
}
function getServiceProviderRole($playerName) {
// 优先从Web.db读取
$db = getDB();
try {
$stmt = $db->prepare("SELECT role FROM web_service_providers WHERE player_name = :p AND active = 1");
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if ($row) return $row['role'];
} catch (\Throwable $e) { /* 表可能不存在 */ }
// 回退:读取Java的login.db
$loginDbPath = GAME_LOGIN_DB;
if (!file_exists($loginDbPath)) return null;
try {
$loginDb = new SQLite3($loginDbPath);
$loginDb->enableExceptions(true);
$stmt = $loginDb->prepare("SELECT role FROM service_providers WHERE player_name = :p AND active = 1");
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
$loginDb->close();
return $row ? $row['role'] : null;
} catch (Exception $e) {
return null;
}
}
// ★ 单张工单附件图上限
define('TICKET_MAX_IMAGES', 3);
/**
* 读取并校验请求里的 images 参数(数组或 JSON 字符串均可),返回 URL 列表。
* 每个 URL 必须以自家图床前缀开头(LSKY_ALLOWED_HOST_PREFIX),最多 3 张。
*/
function normalizeTicketImages() {
$raw = getParam('images', []);
if (is_string($raw)) $raw = json_decode($raw, true);
if ($raw === null || $raw === '') $raw = [];
if (!is_array($raw)) error('images 参数格式错误');
$prefix = LSKY_ALLOWED_HOST_PREFIX;
$out = [];
foreach ($raw as $u) {
if (!is_string($u)) error('图片地址格式错误');
$u = trim($u);
if ($u === '') continue;
if (strpos($u, $prefix) !== 0) error('不支持的图片地址(仅接受自家图床)');
$out[] = $u;
if (count($out) >= TICKET_MAX_IMAGES) break;
}
return $out;
}
function encodeTicketImages(array $list) {
return json_encode(array_values($list), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
function decodeTicketImages($raw) {
if (!is_string($raw) || $raw === '') return [];
$d = json_decode($raw, true);
return is_array($d) ? $d : [];
}
/**
* 上传工单附件图片:转存自家兰空图床,返回可直接引用的 URL。
* 认证:管理后台 session 或用户 token(getAuthUser)。
* 限制:仅 png/jpg(★ 2026-10-04 起按图片真实内容判定,不看文件名后缀)、单张 ≤2048K;同会话每分钟最多 12 次(防刷)。
*/
/**
* ★ 清洗上传文件名(2026-10-04 任务4)
* 全角句点→半角、零宽字符剔除、去老浏览器带的完整路径、去首尾空白。
*/
function sanitizeImageFileName($name) {
// 全角/非法字符 -> 半角合法字符(Windows 会拒收含全角字符的文件名)
$name = str_replace(array(
"\xe3\x80\x8e", // 全角句点
"\xef\xbd\xa1", // 半角浊点
"\xe3\x80\x8f", // 全角斜线
"\xe2\x80\x8b", // 零宽空格
"\xe2\x80\x8c", // 零宽非连接符
"\xe2\x80\x8d", // 零宽连接符
"\xef\xbb\xbf", // BOM
"\xef\xbc\x8c", // 全角 小于号
"\xef\xbc\x9e", // 全角 大于号
"\xef\xbc\x9a", // 全角 冒号
"\xef\xbc\x9f", // 全角 问号
"\xef\xbc\x8a", // 全角 星号
"\xef\xbc\x87", // 全角 双引号
"\xef\xbc\x9c", // 全角 竖线
"\xef\xbc\xa0", // 全角空格
), array(
'.', '.', '/', '', '', '', '',
'_', '_', '_', '_', '_', '_', '_', ' ',
), $name);
// 老浏览器会带完整路径 C:\fakepath\a.png
$name = str_replace('\\', '/', $name);
$pos = strrpos($name, '/');
if ($pos !== false) $name = substr($name, $pos + 1);
return trim($name);
}
/** 后缀归一:jpeg / jpe / jfif -> jpg,其余转小写去点 */
function normalizeImageExt($ext) {
$ext = strtolower(trim((string)$ext));
$ext = str_replace('.', '', $ext);
if (in_array($ext, array('jpeg', 'jpe', 'jfif', 'jif'), true)) return 'jpg';
return $ext;
}
function ticketUploadImage() {
getAuthUser();
// 简单限流:同一会话 1 分钟内最多 12 张
$now = time();
$slot = isset($_SESSION['lsky_up']) && is_array($_SESSION['lsky_up']) ? $_SESSION['lsky_up'] : ['t' => 0, 'n' => 0];
if ($now - (int)$slot['t'] > 60) $slot = ['t' => $now, 'n' => 0];
if ((int)$slot['n'] >= 12) error('图片上传太频繁,请稍后再试');
$slot['n']++;
$_SESSION['lsky_up'] = $slot;
if (empty($_FILES) || empty($_FILES['image'])) {
error('未收到图片文件(表单字段名必须是 image)');
}
$f = $_FILES['image'];
if ((int)$f['error'] !== UPLOAD_ERR_OK) {
$msgs = [
UPLOAD_ERR_INI_SIZE => '图片超过服务器单文件大小上限',
UPLOAD_ERR_FORM_SIZE => '图片超过表单大小上限',
UPLOAD_ERR_PARTIAL => '图片上传中断,请重试',
UPLOAD_ERR_NO_FILE => '未选择文件',
UPLOAD_ERR_NO_TMP_DIR => '服务器临时目录缺失',
UPLOAD_ERR_CANT_WRITE => '服务器写入临时文件失败',
];
$m = isset($msgs[$f['error']]) ? $msgs[$f['error']] : ('上传错误码 ' . $f['error']);
error($m);
}
// 文件名清洗:全角句点→半角、去零宽字符、去首尾空白
$orig = sanitizeImageFileName(basename((string)$f['name']));
// 大小:≤2048K
if ((int)$f['size'] > 2048 * 1024) error('图片不能超过 2048K');
if (!is_uploaded_file($f['tmp_name'])) error('非法上传');
// ★ 2026-10-04 修复:判定「是什么图」只看图片真实内容,不看文件名后缀。
// 旧逻辑用 pathinfo 后缀判类型,导致 .jpeg / 全角句点 / 尾部空格 /
// 无后缀的真实 png、jpg 全被误判成「不是 png/jpg 图片」。
// 这里 getimagesize 认真实格式,防伪造后缀的安全目的依然成立。
$info = @getimagesize($f['tmp_name']);
if ($info === false) error('无法识别的图片内容(请确认文件确实是 png 或 jpg)');
$itype = (int)$info[2];
if ($itype === IMAGETYPE_PNG) $realExt = 'png';
elseif ($itype === IMAGETYPE_JPEG) $realExt = 'jpg';
else error('仅支持 png / jpg 格式');
// 后缀归一:jpeg/jpe/jfif 一律当 jpg
$ext = normalizeImageExt(pathinfo($orig, PATHINFO_EXTENSION));
// 用真实后缀重命名,去掉 .jpeg / 空格 / 全角点等乱七八糟的原名
$base = pathinfo($orig, PATHINFO_FILENAME);
if ($base === '') $base = 'ticket_image';
$orig = mb_substr($base, 0, 60) . '.' . $realExt;
$r = lskyUploadFile($f['tmp_name'], $orig);
if (empty($r['ok'])) error('图片上传失败: ' . (isset($r['msg']) && $r['msg'] !== '' ? $r['msg'] : '未知错误'));
success(['url' => $r['url'], 'thumbnail' => isset($r['thumb']) ? $r['thumb'] : $r['url']], '上传成功');
}
function ticketCreate() {
$info = requireWebToken();
$player = $info['player'];
$type = trim(getParam('type', ''));
$title = trim(getParam('title', ''));
$description = trim(getParam('description', ''));
if (empty($type)) error('请选择工单类型');
if (empty($title)) error('请输入工单标题');
if (mb_strlen($title) > 100) error('标题不能超过100字');
if (mb_strlen($description) > 5000) error('描述不能超过5000字');
$validTypes = ['bug', 'help', 'report', 'apply', 'other'];
if (!in_array($type, $validTypes)) error('无效的工单类型');
$images = normalizeTicketImages(); // 附件图(≤3,必须来自自家图床)
$db = getDB();
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("INSERT INTO web_tickets (type, status, requester, title, description, images, created_at, updated_at) VALUES (:type, 'submitted', :player, :title, :desc, :images, :now, :now)");
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
$stmt->bindValue(':title', $title, SQLITE3_TEXT);
$stmt->bindValue(':desc', $description, SQLITE3_TEXT);
$stmt->bindValue(':images', encodeTicketImages($images), SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
$ticketId = $db->lastInsertRowID();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('创建失败: ' . $e->getMessage());
}
success(['id' => $ticketId], '工单提交成功');
}
function ticketMyList() {
$info = requireWebToken();
$player = $info['player'];
$db = getDB();
$status = getParam('status', '');
$page = max(1, (int)getParam('page', 1));
$limit = 20;
$offset = ($page - 1) * $limit;
$where = "WHERE requester = :player";
$params = [':player' => $player];
if ($status && $status !== 'all') {
$where .= " AND status = :status";
$params[':status'] = $status;
}
$countStmt = $db->prepare("SELECT COUNT(*) as total FROM web_tickets $where");
foreach ($params as $k => $v) $countStmt->bindValue($k, $v, SQLITE3_TEXT);
$total = $countStmt->execute()->fetchArray(SQLITE3_ASSOC)['total'];
$stmt = $db->prepare("SELECT * FROM web_tickets $where ORDER BY created_at DESC LIMIT :limit OFFSET :offset");
foreach ($params as $k => $v) $stmt->bindValue($k, $v, SQLITE3_TEXT);
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$stmt->bindValue(':offset', $offset, SQLITE3_INTEGER);
$result = $stmt->execute();
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
success([
'list' => $list,
'total' => (int)$total,
'page' => $page,
'pages' => ceil($total / $limit)
]);
}
function ticketDetail() {
$id = (int)getParam('id', 0);
if ($id <= 0) error('无效的工单ID');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) error('工单不存在');
// 权限检查:管理员可以直接查看,普通用户只能看自己的工单
if (isAdminLoggedIn()) {
// 管理员,直接放行
} else {
$info = requireWebToken();
$player = $info['player'];
if ($row['requester'] !== $player && $row['assigned_to'] !== $player) {
error('无权查看此工单');
}
}
// 获取回复列表
$replies = getTicketReplies($id);
$row['replies'] = $replies;
// 附件图:老库/老工单没有 images 列时兜底为空数组
$row['images'] = decodeTicketImages(isset($row['images']) ? $row['images'] : null);
success($row);
}
function ticketReply() {
$info = requireWebToken();
$player = $info['player'];
$id = (int)getParam('id', 0);
$message = trim(getParam('message', ''));
if ($id <= 0) error('无效的工单ID');
if (empty($message)) error('回复内容不能为空');
if (mb_strlen($message) > 5000) error('回复内容不能超过5000字');
$db = getDB();
// 检查工单状态和权限
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if ($ticket['requester'] !== $player) error('只能回复自己的工单');
if (in_array($ticket['status'], ['withdrawn', 'rejected'])) error('该工单已关闭,无法回复');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
// 插入回复
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, :sender, 'user', :msg, :now)");
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
$stmt->bindValue(':sender', $player, SQLITE3_TEXT);
$stmt->bindValue(':msg', $message, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
// 更新工单状态为已回复
$stmt = $db->prepare("UPDATE web_tickets SET status = 'replied', updated_at = :now WHERE id = :id");
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('回复失败');
}
success(null, '回复成功');
}
function ticketWithdraw() {
$info = requireWebToken();
$player = $info['player'];
$id = (int)getParam('id', 0);
if ($id <= 0) error('无效的工单ID');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if ($ticket['requester'] !== $player) error('无权操作此工单');
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("UPDATE web_tickets SET status = 'withdrawn', updated_at = :now WHERE id = :id");
$stmt->bindValue(':now', time(), SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('撤销失败');
}
success(null, '工单已撤销');
}
// ===== 管理员端 =====
function ticketListAll() {
requireAdmin();
$db = getDB();
// 兜底建表(服务器core.php可能版本旧)
try {
$db->exec("CREATE TABLE IF NOT EXISTS web_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
status TEXT DEFAULT 'submitted',
requester TEXT NOT NULL,
assigned_to TEXT DEFAULT '',
title TEXT NOT NULL,
description TEXT DEFAULT '',
images TEXT DEFAULT '[]',
reject_reason TEXT DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)");
// ★ 迁移:老库补 images 列(工单附件图 URL 数组 JSON)
try {
$db->exec("ALTER TABLE web_tickets ADD COLUMN images TEXT DEFAULT '[]'");
} catch (Exception $e) { /* 列已存在 */ }
$db->exec("CREATE TABLE IF NOT EXISTS web_ticket_replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
sender TEXT NOT NULL,
role TEXT DEFAULT 'user',
message TEXT NOT NULL,
created_at INTEGER NOT NULL
)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_wt_requester ON web_tickets(requester)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_wt_status ON web_tickets(status)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_wt_assigned ON web_tickets(assigned_to)");
$db->exec("CREATE INDEX IF NOT EXISTS idx_wtr_ticket ON web_ticket_replies(ticket_id)");
} catch (\Throwable $e) {
error('工单表初始化失败: ' . $e->getMessage());
}
try {
$status = getParam('status', 'all');
$page = max(1, (int)getParam('page', 1));
$search = trim(getParam('search', ''));
$limit = 20;
$offset = ($page - 1) * $limit;
$where = "WHERE 1=1";
$params = [];
if ($status && $status !== 'all') {
$where .= " AND status = :status";
$params[':status'] = $status;
}
if ($search) {
$where .= " AND (requester LIKE :search OR title LIKE :search2)";
$params[':search'] = "%$search%";
$params[':search2'] = "%$search%";
}
$countStmt = $db->prepare("SELECT COUNT(*) as total FROM web_tickets $where");
foreach ($params as $k => $v) $countStmt->bindValue($k, $v, SQLITE3_TEXT);
$total = $countStmt->execute()->fetchArray(SQLITE3_ASSOC)['total'];
$stmt = $db->prepare("SELECT * FROM web_tickets $where ORDER BY created_at DESC LIMIT :limit OFFSET :offset");
foreach ($params as $k => $v) $stmt->bindValue($k, $v, SQLITE3_TEXT);
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
$stmt->bindValue(':offset', $offset, SQLITE3_INTEGER);
$result = $stmt->execute();
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
// 统计各状态数量
$stats = [];
foreach (['submitted', 'replied', 'completed', 'withdrawn', 'rejected'] as $s) {
$cnt = $db->querySingle("SELECT COUNT(*) FROM web_tickets WHERE status = '$s'");
$stats[$s] = (int)$cnt;
}
$stats['all'] = (int)$db->querySingle("SELECT COUNT(*) FROM web_tickets");
success([
'list' => $list,
'total' => (int)$total,
'page' => $page,
'pages' => ceil($total / $limit),
'stats' => $stats
]);
} catch (\Throwable $e) {
error('查询工单失败: ' . $e->getMessage());
}
}
function ticketAdminReply() {
requireAdmin();
$id = (int)getParam('id', 0);
$message = trim(getParam('message', ''));
if ($id <= 0) error('无效的工单ID');
if (empty($message)) error('回复内容不能为空');
if (mb_strlen($message) > 5000) error('回复内容不能超过5000字');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, 'admin', 'admin', :msg, :now)");
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
$stmt->bindValue(':msg', $message, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
// 管理员回复 → 状态更新为已回复(不改变assigned_to,保留原服务商所有权)
$stmt = $db->prepare("UPDATE web_tickets SET status = 'replied', updated_at = :now WHERE id = :id");
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('回复失败');
}
success(null, '回复成功');
}
function ticketReject() {
requireAdmin();
$id = (int)getParam('id', 0);
$reason = trim(getParam('reason', ''));
if ($id <= 0) error('无效的工单ID');
if (empty($reason)) error('请输入驳回原因');
if (mb_strlen($reason) > 500) error('驳回原因不能超过500字');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if ($ticket['status'] === 'withdrawn' || $ticket['status'] === 'rejected') error('该工单已关闭');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("UPDATE web_tickets SET status = 'rejected', reject_reason = :reason, updated_at = :now WHERE id = :id");
$stmt->bindValue(':reason', $reason, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('驳回失败');
}
success(null, '工单已驳回');
}
function ticketAssign() {
requireAdmin();
$id = (int)getParam('id', 0);
$provider = trim(getParam('provider', ''));
if ($id <= 0) error('无效的工单ID');
if (empty($provider)) error('请输入服务商名称');
// 验证是否为服务商
if (!isServiceProviderInGame($provider)) error("$provider 不是服务商");
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("UPDATE web_tickets SET assigned_to = :provider, status = 'replied', updated_at = :now WHERE id = :id");
$stmt->bindValue(':provider', $provider, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('分配失败');
}
success(null, "已分配给 $provider");
}
function ticketAdminCreate() {
requireAdmin();
$type = trim(getParam('type', ''));
$title = trim(getParam('title', ''));
$description = trim(getParam('description', ''));
$provider = trim(getParam('provider', ''));
if (empty($type)) error('请选择工单类型');
if (empty($title)) error('请输入工单标题');
if (mb_strlen($title) > 100) error('标题不能超过100字');
if (mb_strlen($description) > 5000) error('描述不能超过5000字');
$validTypes = ['bug', 'help', 'report', 'apply', 'other'];
if (!in_array($type, $validTypes)) error('无效的工单类型');
// 如果指定了服务商,验证身份
if ($provider && !isServiceProviderInGame($provider)) {
error("$provider 不是服务商");
}
$images = normalizeTicketImages(); // 附件图(≤3,必须来自自家图床)
$db = getDB();
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("INSERT INTO web_tickets (type, status, requester, assigned_to, title, description, images, created_at, updated_at) VALUES (:type, 'replied', 'admin', :provider, :title, :desc, :images, :now, :now)");
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
$stmt->bindValue(':provider', $provider, SQLITE3_TEXT);
$stmt->bindValue(':title', $title, SQLITE3_TEXT);
$stmt->bindValue(':desc', $description, SQLITE3_TEXT);
$stmt->bindValue(':images', encodeTicketImages($images), SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
$ticketId = $db->lastInsertRowID();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('创建失败: ' . $e->getMessage());
}
$msg = '工单已创建';
if ($provider) $msg .= ",已派发给 $provider";
success(['id' => $ticketId], $msg);
}
function ticketAdminComplete() {
requireAdmin();
$id = (int)getParam('id', 0);
if ($id <= 0) error('无效的工单ID');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, 'admin', 'admin', '✅ 工单已由管理员标记完结', :now)");
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
$stmt = $db->prepare("UPDATE web_tickets SET status = 'completed', updated_at = :now WHERE id = :id");
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('操作失败');
}
success(null, '已标记完结');
}
// ===== 服务商端 =====
function ticketProviderList() {
$info = requireWebToken();
$player = $info['player'];
// 验证服务商身份
$role = getServiceProviderRole($player);
if (!$role) error('你不是服务商');
$db = getDB();
$type = getParam('type', 'all');
// processing: 活跃(已提交/已回复)
// completed: 已完结/已撤销/已驳回
// all: 全部跟过的工单
$where = "WHERE assigned_to = :player";
$params = [':player' => $player];
if ($type === 'processing') {
$where .= " AND status IN ('submitted', 'replied')";
} elseif ($type === 'completed') {
$where .= " AND status IN ('completed', 'withdrawn', 'rejected')";
}
// 'all' 不加status过滤
$stmt = $db->prepare("SELECT * FROM web_tickets $where ORDER BY updated_at DESC LIMIT 100");
foreach ($params as $k => $v) $stmt->bindValue($k, $v, SQLITE3_TEXT);
$result = $stmt->execute();
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
success(['list' => $list, 'role' => $role]);
}
function ticketAvailable() {
$info = requireWebToken();
$player = $info['player'];
if (!isServiceProviderInGame($player)) error('你不是服务商');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE status = 'submitted' AND assigned_to = '' ORDER BY created_at DESC LIMIT 50");
$result = $stmt->execute();
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
success(['list' => $list]);
}
function ticketGrab() {
$info = requireWebToken();
$player = $info['player'];
$id = (int)getParam('id', 0);
if ($id <= 0) error('无效的工单ID');
if (!isServiceProviderInGame($player)) error('你不是服务商');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if ($ticket['status'] !== 'submitted') error('该工单已被接单');
if (!empty($ticket['assigned_to'])) error('该工单已被分配');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("UPDATE web_tickets SET assigned_to = :provider, updated_at = :now WHERE id = :id");
$stmt->bindValue(':provider', $player, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('抢单失败');
}
success(null, '抢单成功');
}
function ticketProviderReply() {
$info = requireWebToken();
$player = $info['player'];
$id = (int)getParam('id', 0);
$message = trim(getParam('message', ''));
if ($id <= 0) error('无效的工单ID');
if (empty($message)) error('回复内容不能为空');
if (mb_strlen($message) > 5000) error('回复内容不能超过5000字');
if (!isServiceProviderInGame($player)) error('你不是服务商');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if ($ticket['assigned_to'] !== $player) error('这不是分配给你的工单');
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, :sender, 'provider', :msg, :now)");
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
$stmt->bindValue(':sender', $player, SQLITE3_TEXT);
$stmt->bindValue(':msg', $message, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
$stmt = $db->prepare("UPDATE web_tickets SET status = 'replied', updated_at = :now WHERE id = :id");
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('回复失败');
}
success(null, '回复成功');
}
function ticketProviderComplete() {
$info = requireWebToken();
$player = $info['player'];
$id = (int)getParam('id', 0);
if ($id <= 0) error('无效的工单ID');
if (!isServiceProviderInGame($player)) error('你不是服务商');
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$ticket) error('工单不存在');
if ($ticket['assigned_to'] !== $player) error('这不是分配给你的工单');
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
$now = time();
$db->exec('BEGIN IMMEDIATE');
try {
// 插入完结回复
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, :sender, 'provider', '✅ 工单已处理完毕', :now)");
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
$stmt->bindValue(':sender', $player, SQLITE3_TEXT);
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->execute();
// 状态改为completed
$stmt = $db->prepare("UPDATE web_tickets SET status = 'completed', updated_at = :now WHERE id = :id");
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$stmt->execute();
$db->exec('COMMIT');
} catch (Exception $e) {
$db->exec('ROLLBACK');
error('操作失败');
}
success(null, '已标记完结');
}
// ===== 通用 =====
function ticketProviders() {
if (!isAdminLoggedIn()) error('未登录管理后台');
$loginDbPath = GAME_LOGIN_DB;
if (!file_exists($loginDbPath)) success(['list' => []]);
try {
$loginDb = new SQLite3($loginDbPath);
$result = $loginDb->query("SELECT player_name, role, join_time FROM service_providers WHERE active = 1");
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
$loginDb->close();
success(['list' => $list]);
} catch (Exception $e) {
success(['list' => []]);
}
}
function ticketProviderCheck() {
$info = requireWebToken();
$player = $info['player'];
$isProvider = isServiceProviderInGame($player);
$role = $isProvider ? getServiceProviderRole($player) : null;
success(['is_provider' => $isProvider, 'role' => $role]);
}
// ===== 辅助函数 =====
function getTicketReplies($ticketId) {
$db = getDB();
$stmt = $db->prepare("SELECT * FROM web_ticket_replies WHERE ticket_id = :tid ORDER BY created_at ASC");
$stmt->bindValue(':tid', $ticketId, SQLITE3_INTEGER);
$result = $stmt->execute();
$list = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$list[] = $row;
}
return $list;
}
-227
View File
@@ -1,227 +0,0 @@
<?php
/**
* 玩家 2FA (TOTP) 二维码展示中转 + 绑定状态双向同步(第47批)—— PHP 只做显示器与镜像
*
* 2FA 的动态码校验逻辑在 Java 端与 PHP 端各自本地完成(同一 Base32 密钥),
* 这里负责:
* 1) 暂存 otpauth 载荷供玩家浏览器渲染二维码,10 分钟自动过期
* 2) 以 web_account_2fa 为持久镜像,供 Java 端拉取/推送绑定状态
* (修复:网页端绑定后游戏内 /2fa 显示"未绑定")
*
* Java -> PHP(secret 校验):
* push_qr 存一条二维码载荷(token -> otpauth URI)
* pull_state 拉取该玩家网页端绑定状态(bound/secret/bound_at/updated_at)
* push_state 推送 Java 端绑定/解绑结果(网页端无记录或记录更旧时落库)
* 玩家浏览器(持有随机 token 即可读):
* get 按 t 取回 otpauth URI
*
* 同步语义见 Java 端 TwoFactorManager.applyWebState():
* 单向导入(网页绑定游戏未绑定)/ 解绑传播(网页解绑且更晚)/
* 密钥冲突取时间新者;恢复代码两端格式不同(网页16位明文 vs 游戏6位哈希),各自维护。
*/
while (ob_get_level() > 0) { @ob_end_clean(); }
ob_start();
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store, no-cache, must-revalidate');
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
require_once __DIR__ . '/../core.php';
/** 载荷有效期(秒),与 Java 端 twofa_pending_at 的 10 分钟对齐 */
define('TWOFA_QR_TTL', 600);
function twofaEnsureTable($db) {
$db->exec("CREATE TABLE IF NOT EXISTS web_twofa_qr (
token TEXT PRIMARY KEY,
player TEXT NOT NULL,
otpauth TEXT NOT NULL,
expires_at INTEGER NOT NULL
)");
}
function twofaClean($db) {
$db->exec("DELETE FROM web_twofa_qr WHERE expires_at < " . time());
}
/** Java 侧通信校验(与 api/sn.php 同款) */
function twofaRequireSecret() {
$secret = getParam('secret', '');
if ($secret === '' || $secret !== SECRET_KEY) {
error('密钥错误', 403);
}
}
/** token 形态:64 位十六进制(Java SecureRandom 256bit)。
* WAF 实测(2026-10-01 矩阵含重复验证):查询参数名 `token` + ≥32位纯hex 会被
* CF 源站 404 拦截;参数名 `t` 任意长度(16/32/48/64)均可过,POST body 亦可过。
* 故取数接口固定用 `t=`(`token=` 仅作兼容回退)。 */
function twofaValidToken($t) {
return is_string($t) && preg_match('/^[a-f0-9]{32,128}$/', $t) === 1;
}
$action = getParam('action', '');
try {
switch ($action) {
// ===== Java -> PHP:存二维码载荷 =====
case 'push_qr': {
twofaRequireSecret();
$token = (string) getParam('token', '');
$player = (string) getParam('player', '');
$otpauth = (string) getParam('otpauth', '');
if (!twofaValidToken($token)) error('token格式错误', 400);
if ($player === '' || strlen($player) > 32) error('玩家名不合法', 400);
if (strpos($otpauth, 'otpauth://totp/') !== 0 || strlen($otpauth) > 512) {
error('otpauth载荷不合法', 400);
}
$db = getDB();
twofaEnsureTable($db);
twofaClean($db);
$stmt = $db->prepare("INSERT OR REPLACE INTO web_twofa_qr
(token, player, otpauth, expires_at) VALUES (:t, :p, :o, :e)");
$stmt->bindValue(':t', $token, SQLITE3_TEXT);
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
$stmt->bindValue(':o', $otpauth, SQLITE3_TEXT);
$stmt->bindValue(':e', time() + TWOFA_QR_TTL, SQLITE3_INTEGER);
$stmt->execute();
success(['expires_in' => TWOFA_QR_TTL], 'stored');
break;
}
// ===== 玩家浏览器:取二维码载荷 =====
case 'get': {
// 主参数名必须是 t:`token=<32+位hex>` 会被源站 WAF 404,`t=<hex>` 实测全长度可过
$token = (string) getParam('t', '');
if ($token === '') $token = (string) getParam('token', '');
if (!twofaValidToken($token)) error('参数错误', 400);
$db = getDB();
twofaEnsureTable($db);
twofaClean($db);
$stmt = $db->prepare("SELECT player, otpauth, expires_at
FROM web_twofa_qr WHERE token = :t");
$stmt->bindValue(':t', $token, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) error('二维码不存在或已过期', 404);
success([
'player' => $row['player'],
'otpauth' => $row['otpauth'],
'expires_at' => (int) $row['expires_at'],
]);
break;
}
// ===== Java -> PHP:拉取网页端 2FA 绑定状态(第47批双向同步) =====
case 'pull_state': {
twofaRequireSecret();
$player = (string) getParam('player', '');
if ($player === '' || strlen($player) > 32) error('玩家名不合法', 400);
$db = getDB();
$stmt = $db->prepare("SELECT totp_secret, bound_at, updated_at
FROM web_account_2fa WHERE player_name = :p");
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) {
success(['exists' => false, 'bound' => false,
'secret' => '', 'bound_at' => 0, 'updated_at' => 0]);
}
$bound = ((int) $row['bound_at'] > 0 && (string) $row['totp_secret'] !== '');
success([
'exists' => true,
'bound' => $bound,
// 明文密钥仅回给持 SECRET_KEY 的 Java 服务端,绝不出现在浏览器接口
'secret' => $bound ? (string) $row['totp_secret'] : '',
'bound_at' => (int) $row['bound_at'],
'updated_at' => (int) $row['updated_at'],
]);
break;
}
// ===== Java -> PHP:推送 Java 端绑定/解绑结果(第47批双向同步) =====
case 'push_state': {
twofaRequireSecret();
$player = (string) getParam('player', '');
$bound = (string) getParam('bound', '') === '1';
// ★ TOTP 密钥参数名必须是 twofa_secret:'secret' 已被通信密钥占用
$secret = (string) getParam('twofa_secret', '');
$boundAt = (int) getParam('bound_at', '0');
if ($player === '' || strlen($player) > 32) error('玩家名不合法', 400);
if ($bound && !preg_match('/^[A-Z2-7]{16,64}$/', $secret)) {
error('密钥格式错误', 400);
}
$db = getDB();
$stmt = $db->prepare("SELECT bound_at, recovery_codes
FROM web_account_2fa WHERE player_name = :p");
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
$existingAt = $row ? (int) $row['bound_at'] : -1; // -1 = 无记录
$now = time();
if ($bound) {
// 仅当网页端无记录、未绑定、或绑定时间更旧时覆盖;
// 网页端已绑定且不比我们旧 → 不动(网页端用户主动操作优先)
$webNewer = ($existingAt > 0 && $boundAt > 0 && $existingAt >= $boundAt);
if ($webNewer) {
success(['applied' => false, 'reason' => 'web_side_newer']);
}
// 游戏端恢复代码是 SHA-256 哈希且格式与网页端(16位明文 JSON)不兼容,
// 推送时清空网页端恢复代码列表,避免网页端用旧代码通过第二层验证
$stmt = $db->prepare("INSERT INTO web_account_2fa
(player_name, totp_secret, recovery_codes, bound_at, updated_at)
VALUES (:p, :s, '', :b, :u)
ON CONFLICT(player_name) DO UPDATE SET
totp_secret = :s, recovery_codes = '',
bound_at = :b, updated_at = :u");
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
$stmt->bindValue(':s', $secret, SQLITE3_TEXT);
$stmt->bindValue(':b', $boundAt > 0 ? $boundAt : $now, SQLITE3_INTEGER);
$stmt->bindValue(':u', $now, SQLITE3_INTEGER);
$stmt->execute();
success(['applied' => true, 'action' => 'bound']);
} else {
// 解绑:仅当网页端记录不比我们新时清空(防覆盖网页端更晚的重新绑定)
$webNewer = ($existingAt > 0 && $boundAt > 0 && $existingAt > $boundAt);
if ($webNewer) {
success(['applied' => false, 'reason' => 'web_side_newer']);
}
if ($existingAt === 0) {
success(['applied' => false, 'reason' => 'already_unbound']);
}
if ($existingAt > 0) {
$stmt = $db->prepare("UPDATE web_account_2fa SET
totp_secret = '', recovery_codes = '', bound_at = 0, updated_at = :u
WHERE player_name = :p");
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
$stmt->bindValue(':u', $now, SQLITE3_INTEGER);
$stmt->execute();
}
success(['applied' => true, 'action' => 'unbound']);
}
break;
}
default:
error('未知操作', 400);
}
} catch (\Throwable $e) {
debugLog('twofa异常: ' . $e->getMessage(), ['action' => $action]);
error('服务器内部错误', 500);
}
-179
View File
@@ -1,179 +0,0 @@
<?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');
-494
View File
@@ -1,494 +0,0 @@
<?php
/**
* 人机验证码统一接入(CF Turnstile + VAPTCHA V4)
*
* 覆盖三大场景:
* 1. 管理员登录 admin_login.php → api/admin.php adminDoLogin()
* 2. 用户后台账密登录 login.php(密码页签) → api/sync.php webLoginRequest()
* 3. 正版验证提交前 api/minecraft_auth.php paste_code_page → verify_code
*
* 用哪一家由 config.php 的 CAPTCHA_PROVIDER 决定(未定义时默认 CF):
* 'CF' / 'cloudflare' / 'turnstile'(不区分大小写) → Cloudflare Turnstile
* 'VA' / 'vaptcha' (不区分大小写) → VAPTCHA V4
* '' / 'off' / 'none' → 关闭人机验证
*
* 密钥来源(与 captcha_test.php 同一套优先级,URL 参数除外):
* captcha_keys.php > config.php 常量 > robots.json
*
* 安全策略:
* - 密钥完全没配 → 放行(本地开发/未启用环境不受影响),记日志
* - 密钥已配但没提交 token → 拒绝
* - 校验请求网络不通(传输层失败)→ 放行 + 告警日志(避免第三方故障把所有人锁在门外)
* - 校验接口正常返回但判定不通过 → 拒绝
*
* 本文件自包含,不依赖 core.php(api/minecraft_auth.php 不引 core.php)。
*/
// ====================================================================
// 配置解析
// ====================================================================
require_once __DIR__ . '/config.php'; // 取 CAPTCHA_PROVIDER(require_once,不会重复定义)
/** 取当前启用的验证码厂商:'cf' | 'va' | ''(关闭) */
function cgProvider() {
$raw = defined('CAPTCHA_PROVIDER') ? (string) CAPTCHA_PROVIDER : 'CF';
$v = strtolower(trim($raw));
if ($v === '' || $v === 'off' || $v === 'none' || $v === 'false' || $v === '0') return '';
if (in_array($v, ['va', 'v', 'vaptcha', 'vaptcha-v4', 'vaptcha_v4'], true)) return 'va';
// cf / cloudflare / turnstile / 未识别值 → 一律按 CF 处理(默认厂商)
return 'cf';
}
/** 读取密钥(四级回退,静态缓存)。 */
function cgKeys() {
static $k = null;
if ($k !== null) return $k;
$k = ['source' => '未配置', 'cf_sitekey' => '', 'cf_secret' => '',
'v_vid' => '', 'v_key' => '', 'v_scene' => 0];
// 1) captcha_keys.php(独立密钥文件,真实值只在服务器上,仓库里是空模板)
$f = __DIR__ . '/captcha_keys.php';
if (is_file($f)) {
$CF_SITEKEY = ''; $CF_SECRET = ''; $VAPTCHA_VID = ''; $VAPTCHA_VKEY = ''; $VAPTCHA_SCENE = 0;
@include $f;
if ($CF_SITEKEY !== '' || $VAPTCHA_VID !== '') {
$k = ['source' => 'captcha_keys.php', 'cf_sitekey' => (string) $CF_SITEKEY,
'cf_secret' => (string) $CF_SECRET, 'v_vid' => (string) $VAPTCHA_VID,
'v_key' => (string) $VAPTCHA_VKEY, 'v_scene' => (int) $VAPTCHA_SCENE];
return $k;
}
}
// 2) config.php 常量
if (defined('CF_TURNSTILE_SITEKEY') || defined('VAPTCHA_VID')) {
$k = ['source' => 'config.php 常量',
'cf_sitekey' => defined('CF_TURNSTILE_SITEKEY') ? CF_TURNSTILE_SITEKEY : '',
'cf_secret' => defined('CF_TURNSTILE_SECRET') ? CF_TURNSTILE_SECRET : '',
'v_vid' => defined('VAPTCHA_VID') ? VAPTCHA_VID : '',
'v_key' => defined('VAPTCHA_VKEY') ? VAPTCHA_VKEY : '',
'v_scene' => defined('VAPTCHA_SCENE') ? VAPTCHA_SCENE : 0];
if ($k['cf_sitekey'] !== '' || $k['v_vid'] !== '') return $k;
}
// 3) robots.json 兜底(严禁入库的密钥清单)
$rj = __DIR__ . '/robots.json';
if (is_file($rj)) {
$arr = json_decode((string) @file_get_contents($rj), true);
if (is_array($arr)) {
foreach ($arr as $item) {
if (!is_array($item) || !isset($item['名字'])) continue;
if ($item['名字'] === 'CF') {
$k['cf_sitekey'] = (string) ($item['站点密钥'] ?? '');
$k['cf_secret'] = (string) ($item['密钥'] ?? '');
} elseif ($item['名字'] === 'vaptcha') {
$k['v_vid'] = (string) ($item['VID'] ?? '');
$k['v_key'] = (string) ($item['VKey'] ?? '');
$k['v_scene'] = 0;
}
}
if ($k['cf_sitekey'] !== '' || $k['v_vid'] !== '') $k['source'] = 'robots.json';
}
}
return $k;
}
/** 当前厂商的密钥是否齐全。 */
function cgConfigured($p = null) {
$p = ($p === null) ? cgProvider() : $p;
if ($p === '') return false;
$k = cgKeys();
return ($p === 'cf') ? ($k['cf_sitekey'] !== '' && $k['cf_secret'] !== '')
: ($k['v_vid'] !== '' && $k['v_key'] !== '');
}
/** 是否需要做整套人机验证(厂商开启 + 密钥齐全)。 */
function cgActive() {
return cgConfigured(cgProvider());
}
// ====================================================================
// 日志
// ====================================================================
function cgLog($msg) {
$line = '[' . date('Y-m-d H:i:s') . '] [captcha] ' . $msg . "\n";
@error_log('[captcha] ' . $msg);
if (function_exists('debugLog')) {
debugLog('captcha', ['msg' => $msg]);
return;
}
$dir = __DIR__ . '/db';
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
$f = $dir . '/debug.log';
@file_put_contents($f, $line, 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($f); }
}
}
// ====================================================================
// HTTP(PHP 8.5 起 curl_close 是 Deprecated,会污染 JSON —— 一律不调用)
// ====================================================================
function cgHttpPostForm($url, array $fields) {
$r = ['errno' => -1, 'status' => 0, 'body' => '', 'err' => ''];
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($fields),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$body = curl_exec($ch);
$r['status'] = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$r['err'] = curl_error($ch);
if ($body !== false) { $r['body'] = (string) $body; $r['errno'] = 0; }
return $r;
}
$ctx = stream_context_create(['http' => [
'method' => 'POST', 'timeout' => 10,
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
'content' => http_build_query($fields)]]);
$body = @file_get_contents($url, false, $ctx);
if ($body !== false) { $r['body'] = (string) $body; $r['errno'] = 0; $r['err'] = 'file_get_contents'; }
else $r['err'] = '无 curl 且请求失败';
return $r;
}
function cgHttpPostJson($url, array $payload) {
$r = ['errno' => -1, 'status' => 0, 'body' => '', 'err' => ''];
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $json,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$body = curl_exec($ch);
$r['status'] = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$r['err'] = curl_error($ch);
if ($body !== false) { $r['body'] = (string) $body; $r['errno'] = 0; }
return $r;
}
$ctx = stream_context_create(['http' => [
'method' => 'POST', 'timeout' => 10,
'header' => "Content-Type: application/json\r\n",
'content' => $json]]);
$body = @file_get_contents($url, false, $ctx);
if ($body !== false) { $r['body'] = (string) $body; $r['errno'] = 0; $r['err'] = 'file_get_contents'; }
else $r['err'] = '无 curl 且请求失败';
return $r;
}
// ====================================================================
// 取客户端 IP
// ====================================================================
function cgClientIp() {
if (function_exists('secClientIp')) {
$ip = secClientIp();
if (is_string($ip) && $ip !== '') return $ip;
}
return (string) ($_SERVER['REMOTE_ADDR'] ?? '');
}
// ====================================================================
// 从请求里收集验证码字段(POST 表单 / GET / JSON body 三路通吃)
// ====================================================================
function cgInput() {
$keys = ['captcha_provider', 'captcha_token', 'captcha_knock', 'captcha_dfu', 'captcha_ip'];
$raw = null; // JSON body 缓存
$rawRead = false;
$out = [];
foreach ($keys as $k) {
$v = '';
if (isset($_POST[$k])) $v = $_POST[$k];
elseif (isset($_GET[$k])) $v = $_GET[$k];
elseif (function_exists('getParam')) {
// core.php 的 getParam 会解析 JSON body;minecraft_auth.php 的只读 GET(上面已兜过)
$v = getParam($k, '');
}
$out[$k] = is_string($v) ? trim($v) : '';
}
return $out;
}
// ====================================================================
// 服务端校验
// 返回 ['ok'=>bool, 'msg'=>string, 'skipped'=>bool]
// ====================================================================
function cgVerify(array $in) {
$p = cgProvider();
if ($p === '') return ['ok' => true, 'skipped' => true, 'msg' => '验证码已关闭'];
$k = cgKeys();
if ($p === 'cf' && ($k['cf_sitekey'] === '' || $k['cf_secret'] === '')) {
cgLog('skip: CF 密钥未配置 (source=' . $k['source'] . ')');
return ['ok' => true, 'skipped' => true, 'msg' => 'CF 密钥未配置,已跳过'];
}
if ($p === 'va' && ($k['v_vid'] === '' || $k['v_key'] === '')) {
cgLog('skip: VAPTCHA 密钥未配置 (source=' . $k['source'] . ')');
return ['ok' => true, 'skipped' => true, 'msg' => 'VAPTCHA 密钥未配置,已跳过'];
}
// 前端渲染的厂商必须和服务端配置一致(不一致=篡改或配置漂移)
$claimed = strtolower($in['captcha_provider'] ?? '');
if ($claimed !== '' && $claimed !== $p) {
cgLog('reject: provider mismatch claimed=' . $claimed . ' configured=' . $p);
return ['ok' => false, 'skipped' => false, 'msg' => '人机验证配置不匹配,请刷新页面重试'];
}
$token = (string) ($in['captcha_token'] ?? '');
if ($token === '' || strlen($token) > 4096) {
cgLog('reject: 空 token (provider=' . $p . ')');
return ['ok' => false, 'skipped' => false, 'msg' => '请先完成人机验证'];
}
if ($p === 'cf') return cgVerifyCf($k, $token);
return cgVerifyVa($k, $in, $token);
}
function cgVerifyCf(array $k, $token) {
$endpoint = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
$body = ['secret' => $k['cf_secret'], 'response' => $token];
$ip = cgClientIp();
if ($ip !== '') $body['remoteip'] = $ip;
$r = cgHttpPostForm($endpoint, $body);
if ($r['errno'] !== 0 || $r['status'] === 0) {
cgLog('fail-open: CF siteverify 传输失败 status=' . $r['status'] . ' err=' . $r['err']);
return ['ok' => true, 'skipped' => true, 'msg' => '验证码服务不可用,已放行'];
}
$j = json_decode($r['body'], true);
if (is_array($j) && !empty($j['success'])) {
cgLog('pass: CF (hostname=' . (isset($j['hostname']) ? $j['hostname'] : '-') . ')');
return ['ok' => true, 'skipped' => false, 'msg' => 'ok'];
}
$errc = is_array($j) && isset($j['error-codes']) ? implode(',', (array) $j['error-codes']) : 'no-json';
cgLog('reject: CF http=' . $r['status'] . ' codes=' . $errc . ' body=' . substr($r['body'], 0, 200));
return ['ok' => false, 'skipped' => false, 'msg' => '人机验证未通过,请重试'];
}
function cgVerifyVa(array $k, array $in, $token) {
$knock = substr((string) ($in['captcha_knock'] ?? ''), 0, 256);
$dfu = substr((string) ($in['captcha_dfu'] ?? ''), 0, 512);
$sip = substr((string) ($in['captcha_ip'] ?? ''), 0, 64);
if ($sip === '') $sip = cgClientIp();
$endpoint = 'https://v41.vaptcha.com/api/verify';
$r = cgHttpPostJson($endpoint, [
'vid' => $k['v_vid'],
'vkey' => $k['v_key'],
'token' => $token,
'knock' => $knock,
'dfu' => $dfu,
'ip' => $sip,
]);
if ($r['errno'] !== 0 || $r['status'] === 0) {
cgLog('fail-open: VAPTCHA verify 传输失败 status=' . $r['status'] . ' err=' . $r['err']);
return ['ok' => true, 'skipped' => true, 'msg' => '验证码服务不可用,已放行'];
}
$j = json_decode($r['body'], true);
$code = is_array($j) && isset($j['code']) ? (int) $j['code'] : -1;
$res = is_array($j) && isset($j['data']['result']) ? (bool) $j['data']['result'] : false;
if ($code === 0 && $res) {
cgLog('pass: VAPTCHA V4');
return ['ok' => true, 'skipped' => false, 'msg' => 'ok'];
}
cgLog('reject: VAPTCHA http=' . $r['status'] . ' code=' . $code . ' result=' . var_export($res, true)
. ' body=' . substr($r['body'], 0, 200));
return ['ok' => false, 'skipped' => false, 'msg' => '人机验证未通过,请重试'];
}
/**
* 强制校验:不通过就按当前环境的 error() 约定输出 JSON 并终止。
* 三大 API 入口各调一次(Java 带 secret 的调用不经过这里)。
*/
function cgEnforce($context = '') {
$r = cgVerify(cgInput());
if ($r['ok']) return;
cgLog('enforce-block' . ($context !== '' ? ' ctx=' . $context : '') . ' msg=' . $r['msg']);
if (function_exists('error')) error($r['msg'], 403);
http_response_code(403);
header('Content-Type: application/json; charset=utf-8');
$payload = ['success' => false, 'message' => $r['msg'], 'error' => $r['msg']];
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
}
// ====================================================================
// 前端挂件(HTML + 脚本),未启用时返回空串
// ====================================================================
function cgWidgetHtml() {
if (!cgActive()) return '';
$p = cgProvider();
$k = cgKeys();
$sitekey = $p === 'cf' ? $k['cf_sitekey'] : '';
$vid = $p === 'va' ? $k['v_vid'] : '';
$tpl = <<<'CGTPL'
<style>
.cg-box{margin:0 0 16px;padding:14px 14px 12px;background:#0d1117;border:1px solid #30363d;
border-radius:8px;text-align:left}
.cg-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px}
.cg-title{font-size:13px;color:#8b949e}
.cg-badge{font-size:12px;padding:2px 9px;border-radius:999px;background:#21262d;color:#8b949e}
.cg-badge.ok{background:#1a7f37;color:#fff}
.cg-badge.bad{background:#da3633;color:#fff}
.cg-badge.warn{background:#9e6a03;color:#fff}
.cg-err{display:none;margin-top:8px;font-size:12.5px;color:#f85149}
#cgCfBox{width:100%}
.cg-btn{width:100%;padding:11px 14px;font-size:14px;font-weight:600;border:none;border-radius:6px;
background:#21262d;color:#e6edf3;cursor:not-allowed}
.cg-btn:enabled{background:#238636;color:#fff;cursor:pointer}
.cg-btn:enabled:hover{background:#2ea043}
</style>
<div class="cg-box" id="cgBox" data-provider="__PROVIDER__">
<div class="cg-head"><span class="cg-title">🛡 人机验证</span><span class="cg-badge" id="cgBadge">加载中…</span></div>
<div id="cgCfBox" style="display:none"></div>
<div id="cgVaBox" style="display:none"></div>
<button type="button" class="cg-btn" id="cgVaBtn" style="display:none" disabled>点击完成人机验证</button>
<div class="cg-err" id="cgErr"></div>
<input type="hidden" name="captcha_provider" id="captcha_provider" value="__PROVIDER__">
<input type="hidden" name="captcha_token" id="captcha_token" value="">
<input type="hidden" name="captcha_knock" id="captcha_knock" value="">
<input type="hidden" name="captcha_dfu" id="captcha_dfu" value="">
<input type="hidden" name="captcha_ip" id="captcha_ip" value="">
</div>
<script>
(function(){
var P = '__PROVIDER__';
var st = {tok:'',knock:'',dfu:'',ip:'',err:'',vaObj:null,vaBusy:false,vaDone:false};
function el(id){return document.getElementById(id);}
function put(){el('captcha_token').value=st.tok;el('captcha_knock').value=st.knock;
el('captcha_dfu').value=st.dfu;el('captcha_ip').value=st.ip;}
function badge(t,c){var b=el('cgBadge');if(!b)return;b.textContent=t;b.className='cg-badge '+(c||'');}
function fail(m){st.err=m;badge('验证未通过','bad');var e=el('cgErr');
if(e){e.textContent=m;e.style.display='block';}}
function collect(){return {captcha_provider:P,captcha_token:st.tok,captcha_knock:st.knock,
captcha_dfu:st.dfu,captcha_ip:st.ip};}
function loadScript(src,ok,failFn){var s=document.createElement('script');s.src=src;s.async=true;
s.onload=ok;s.onerror=failFn;document.head.appendChild(s);}
// ★ 第十九批:Turnstile 挂件固定 300px 宽,容器(如管理后台登录卡)比它窄时右侧溢出边框 ——
// 按容器宽度整体等比缩放(原点左上),视觉与 .cg-box 边框对齐;容器够宽则还原不缩放
function fitCf(){
var box=el('cgCfBox'); if(!box) return;
var w=box.clientWidth||0;
if(!w) return;
if(w>=300){ box.style.transform=''; box.style.marginBottom=''; return; }
var s=w/300;
box.style.transformOrigin='top left';
box.style.transform='scale('+s+')';
box.style.marginBottom=(65*s-65)+'px';
}
function initCf(){
el('cgCfBox').style.display='block';
// ★ 关键:本页必须自己拉起 Turnstile SDK(测试页是 <script src> 引的,本挂件漏了)
if (typeof turnstile === 'undefined' && !window.__cgSdkLoading){
window.__cgSdkLoading = true;
loadScript('https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit',
function(){ window.__cgSdkLoading = false; window.__cgSdkOk = true; },
function(){ window.__cgSdkLoading = false; window.__cgSdkErr = true; });
}
var tries=0;
(function wait(){
if (typeof turnstile === 'undefined'){
if (window.__cgSdkErr){ fail('Turnstile SDK 加载失败(网络不通或域名未加入白名单),请刷新重试'); return; }
if (++tries > 96){ fail('验证组件加载失败(网络或域名未加入白名单),请刷新重试'); return; }
return setTimeout(wait,250);
}
badge('请完成验证','');
try{
turnstile.render('#cgCfBox', {
sitekey: '__SITEKEY__',
theme: 'auto',
callback: function(t){ st.tok=t; st.err=''; put(); badge('已通过','ok');
var e=el('cgErr'); if(e) e.style.display='none'; },
'error-callback': function(m){ fail('验证错误: '+m); },
'expired-callback': function(){ st.tok=''; put(); badge('已过期,请重新勾选','warn'); }
});
// widget 的 iframe 是异步插入的,分几次校准(含低网速兜底)
setTimeout(fitCf, 400);
setTimeout(fitCf, 1500);
setTimeout(fitCf, 4000);
}catch(ex){ fail('render 异常: '+ex.message); }
})();
}
function initVa(){
el('cgVaBox').style.display='block';
var btn=el('cgVaBtn'); btn.style.display='block';
loadScript('https://c4.vaptcha.com/src/v4.js', function(){
if (typeof vaptcha !== 'function'){ fail('VAPTCHA SDK 异常'); return; }
vaptcha({vid:'__VID__', container:'#cgVaBox', lang:'zh-CN'}).then(function(obj){
st.vaObj=obj; badge('准备就绪','');
btn.disabled=false;
btn.addEventListener('click', function(){ validateVa(function(){}); });
}).catch(function(e){ fail('VAPTCHA 初始化失败: '+(e&&e.message?e.message:e)); });
}, function(){ fail('VAPTCHA SDK 加载失败(c4.vaptcha.com 不可达)'); });
}
function validateVa(done){
if (st.tok){ done(true); return; }
if (!st.vaObj){ fail(st.err || '人机验证组件未就绪,请稍候重试'); done(false); return; }
if (st.vaBusy) return;
st.vaBusy=true;
badge('验证中…','');
Promise.resolve(st.vaObj.validate()).then(function(r){
st.vaBusy=false;
if (!r || !r.token){ fail('未完成人机验证'); done(false); return; }
st.tok=r.token; st.knock=r.knock||''; st.dfu=r.dfu||''; st.ip=r.ip||'';
st.err=''; st.vaDone=true; put(); badge('已通过','ok');
var e=el('cgErr'); if(e) e.style.display='none';
var b=el('cgVaBtn'); if(b){ b.textContent='人机验证已完成'; b.disabled=true; }
done(true);
}).catch(function(e){
st.vaBusy=false;
fail('人机验证失败: '+(e&&e.message?e.message:e));
done(false);
});
}
window.CaptchaGuard = {
provider: P,
payload: function(){
return new Promise(function(resolve,reject){
if (st.tok) { resolve(collect()); return; }
if (P === 'cf'){
if (st.err){ reject(new Error(st.err)); return; }
var t0=Date.now();
var iv=setInterval(function(){
if (st.tok){ clearInterval(iv); resolve(collect()); }
else if (st.err){ clearInterval(iv); reject(new Error(st.err)); }
else if (Date.now()-t0 > 60000){ clearInterval(iv);
reject(new Error('人机验证超时,请刷新页面重试')); }
},200);
return;
}
if (st.err){ reject(new Error(st.err)); return; }
validateVa(function(ok){
if (ok) resolve(collect());
else reject(new Error(st.err || '请先完成人机验证'));
});
});
}
};
window.addEventListener('resize', fitCf);
if (P === 'cf') initCf(); else initVa();
})();
</script>
CGTPL;
$html = str_replace(['__PROVIDER__', '__SITEKEY__', '__VID__'],
[$p, $sitekey, $vid], $tpl);
return $html;
}
-46
View File
@@ -1,46 +0,0 @@
<?php
/**
* 人机验证码密钥(模板版 —— 里面没有任何真实密钥,可安全入库/上传)
*
* ⚠️ 本文件已在 .gitignore 中排除,线上真实密钥请直接在服务器上编辑本文件填写。
* 填好后不要再把真实值提交进 git。
*
* ── 怎么填 ──────────────────────────────────────────────
*
* 1) Cloudflare Turnstile(默认使用的验证码,体验最好、无感)
* 申请入口:Cloudflare 控制台 → Workers & Pages / Turnstile → Add site
* https://dash.cloudflare.com/?to=/:account/turnstile
* 绑定域名自己的域名(不一定要托管在CF)
* 拿到两个值:
* $CF_SITEKEY → 页面上显示的 Sitekey(0x4AAAA 开头)
* $CF_SECRET → 后台的 Secret key(0x4AAAA 开头,不要和 sitekey 搞混)
*
* 2) VAPTCHA(备用验证码,配置 CAPTCHA_PROVIDER = 'VA' 时启用)
* 申请入口:VAPTCHA 官网控制台 → 创建应用 → 拿 VID / VKey
* https://www.vaptcha.com/
* 注意:V3 的 v-cn/cdn/api.vaptcha.com 已全部失效,本项目走 V4
* SDK:c4.vaptcha.com/src/v4.js
* 校验:v41.vaptcha.com/api/verify
* 拿到两个值:
* $VAPTCHA_VID → 形如 id_xxxxxxxxxxxxxxxx
* $VAPTCHA_VKEY → 形如 key_xxxxxxxxxxxxxxxx
* $VAPTCHA_SCENE → 场景号,不确定就填 0
*
* 3) 用哪个验证码由 config.php 里的 CAPTCHA_PROVIDER 决定:
* define('CAPTCHA_PROVIDER', 'CF'); // 默认 CF(Cloudflare)
* define('CAPTCHA_PROVIDER', 'VA'); // 或 VAPTCHA
* 兼容写法:cloudflare / turnstile / cf(不区分大小写)都算 CF;
* vaptcha / va(不区分大小写)都算 VA。
*
* 4) 填完本文件后,可用 captcha_test.php 自测连通性。
* ───────────────────────────────────────────────────────
*/
// ===== Cloudflare Turnstile =====
$CF_SITEKEY = ''; // 例:'0x4AAAA...站点密钥'
$CF_SECRET = ''; // 例:'0x4AAAA...密钥'
// ===== VAPTCHA (V4) =====
$VAPTCHA_VID = ''; // 例:'id_xxxxxxxxxxxxxxxx'
$VAPTCHA_VKEY = ''; // 例:'key_xxxxxxxxxxxxxxxx'
$VAPTCHA_SCENE = 0; // 场景号,默认 0
-511
View File
@@ -1,511 +0,0 @@
<?php
/**
* 人机验证码接口打通测试页(暂不接入任何登录流程)
*
* 上下布局同时接入:
* 1. Cloudflare Turnstile
* 2. VAPTCHA V4(V3 的 v-cn/cdn/0/api.vaptcha.com 已全部 NXDOMAIN,2026-10-01 切 V4)
* 各自渲染 -> 出 token -> 回调本页 action=verify 做服务端二次校验 -> 展示官方接口原文。
* VAPTCHA 服务端两种方式:ep=1 官方 v41.vaptcha.com/api/verify;ep=2 本地 HMAC-SHA256 验签。
*
* 密钥来源(软依赖,按优先级):
* captcha_keys.php(不入库)> config.php 常量 > URL参数(临时)> robots.json(兜底)
* robots.json / captcha_keys.php 均严禁入库(前者已在 .gitignore)。
*/
// ==================== 服务端二次校验代理 ====================
if (isset($_GET['action']) && $_GET['action'] === 'verify') {
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
ini_set('display_errors', '0'); // PHP 8.5 的 Deprecated 告警不得污染 JSON
$k = captchaTestKeys();
$provider = isset($_GET['provider']) ? $_GET['provider'] : '';
$token = isset($_GET['token']) ? (string) $_GET['token'] : '';
$ep = isset($_GET['ep']) ? (int) $_GET['ep'] : 1;
if ($token === '' || strlen($token) > 2048) {
echo json_encode(['ok' => false, 'err' => 'token 为空'], JSON_UNESCAPED_UNICODE);
exit;
}
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if ($provider === 'cf') {
if ($k['cf_secret'] === '') {
echo json_encode(['ok' => false, 'err' => 'CF 密钥(secret)未配置'], JSON_UNESCAPED_UNICODE);
exit;
}
$endpoint = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
$body = ['secret' => $k['cf_secret'], 'response' => $token];
if ($ip !== '') $body['remoteip'] = $ip;
$r = captchaHttpPostForm($endpoint, $body);
echo json_encode([
'ok' => $r['errno'] === 0, 'provider' => 'CF Turnstile',
'endpoint' => $endpoint, 'http' => $r['status'],
'body' => $r['body'], 'err' => $r['err'],
], JSON_UNESCAPED_UNICODE);
exit;
}
if ($provider === 'vaptcha') {
if ($k['v_key'] === '' || $k['v_vid'] === '') {
echo json_encode(['ok' => false, 'err' => 'vaptcha VID/Key 未配置'], JSON_UNESCAPED_UNICODE);
exit;
}
// VAPTCHA V4 二次验证两种方式(2026-10-01 官方文档:document/install)
// ep=1(默认)官方 verify 接口;ep=2 本地 HMAC-SHA256 验签(token = ts.id.sig)
$knock = isset($_GET['knock']) ? (string) $_GET['knock'] : '';
$dfu = isset($_GET['dfu']) ? (string) $_GET['dfu'] : '';
$sip = isset($_GET['ip']) ? (string) $_GET['ip'] : $ip; // 优先 SDK 签名 IP
if (strlen($knock) > 256 || strlen($dfu) > 512 || strlen($sip) > 64) {
echo json_encode(['ok' => false, 'err' => 'knock/dfu/ip 参数过长'], JSON_UNESCAPED_UNICODE);
exit;
}
if ($ep === 2) {
// 方式二回退:本地验签(文档给的官方签名规则)
$parts = explode('.', $token);
$pass = false; $why = '';
if (count($parts) !== 3) { $why = 'token 非三段式'; }
elseif (abs(time() - (int) $parts[0]) > 180) { $why = 'token 超过 180s 有效期'; }
else {
$expected = hash_hmac('sha256', $parts[0] . '.' . $sip . '.' . $dfu . '.' . $knock, $k['v_key']);
$pass = hash_equals($expected, $parts[2]);
if (!$pass) $why = '签名校验不通过';
}
echo json_encode([
'ok' => true, 'provider' => 'VAPTCHA V4 本地验签', 'mode' => 'local-hmac',
'pass' => $pass, 'why' => $why,
], JSON_UNESCAPED_UNICODE);
exit;
}
$endpoint = 'https://v41.vaptcha.com/api/verify';
$payload = [
'vid' => $k['v_vid'],
'vkey' => $k['v_key'],
'token' => $token,
'knock' => $knock,
'dfu' => $dfu,
'ip' => $sip,
];
$r = captchaHttpPostJson($endpoint, $payload);
$decoded = json_decode($r['body'], true);
echo json_encode([
'ok' => $r['errno'] === 0, 'provider' => 'VAPTCHA V4', 'mode' => 'official-verify',
'endpoint' => $endpoint, 'http' => $r['status'],
'result' => is_array($decoded) ? ($decoded['data']['result'] ?? null) : null,
'body' => $r['body'], 'err' => $r['err'],
], JSON_UNESCAPED_UNICODE);
exit;
}
echo json_encode(['ok' => false, 'err' => '未知 provider'], JSON_UNESCAPED_UNICODE);
exit;
}
// ==================== 密钥解析(软依赖四级回退) ====================
function captchaTestKeys() {
static $cache = null;
if ($cache !== null) return $cache;
$k = ['source' => '未配置', 'cf_sitekey' => '', 'cf_secret' => '',
'v_vid' => '', 'v_key' => '', 'v_scene' => 0];
// 1) captcha_keys.php(独立密钥文件,不入库)
$f = __DIR__ . '/captcha_keys.php';
if (is_file($f)) {
$CF_SITEKEY = ''; $CF_SECRET = ''; $VAPTCHA_VID = ''; $VAPTCHA_VKEY = ''; $VAPTCHA_SCENE = 0;
@include $f;
if ($CF_SITEKEY !== '' || $VAPTCHA_VID !== '') {
$k = ['source' => 'captcha_keys.php', 'cf_sitekey' => (string) $CF_SITEKEY,
'cf_secret' => (string) $CF_SECRET, 'v_vid' => (string) $VAPTCHA_VID,
'v_key' => (string) $VAPTCHA_VKEY, 'v_scene' => (int) $VAPTCHA_SCENE];
return $cache = $k;
}
}
// 2) config.php 常量(config.php 本身不入库)
if (defined('CF_TURNSTILE_SITEKEY') || defined('VAPTCHA_VID')) {
$k = ['source' => 'config.php 常量',
'cf_sitekey' => defined('CF_TURNSTILE_SITEKEY') ? CF_TURNSTILE_SITEKEY : '',
'cf_secret' => defined('CF_TURNSTILE_SECRET') ? CF_TURNSTILE_SECRET : '',
'v_vid' => defined('VAPTCHA_VID') ? VAPTCHA_VID : '',
'v_key' => defined('VAPTCHA_VKEY') ? VAPTCHA_VKEY : '',
'v_scene' => defined('VAPTCHA_SCENE') ? VAPTCHA_SCENE : 0];
if ($k['cf_sitekey'] !== '' || $k['v_vid'] !== '') return $cache = $k;
}
// 3) URL 参数(临时联调)
$gcf = isset($_GET['cf_sitekey']) ? trim((string) $_GET['cf_sitekey']) : '';
$gvid = isset($_GET['v_vid']) ? trim((string) $_GET['v_vid']) : '';
if ($gcf !== '' || $gvid !== '') {
$k = ['source' => 'URL参数(临时)', 'cf_sitekey' => $gcf,
'cf_secret' => isset($_GET['cf_secret']) ? (string) $_GET['cf_secret'] : '',
'v_vid' => $gvid,
'v_key' => isset($_GET['v_key']) ? (string) $_GET['v_key'] : '',
'v_scene' => isset($_GET['v_scene']) ? (int) $_GET['v_scene'] : 0];
return $cache = $k;
}
// 4) robots.json 兜底(严禁入库的密钥清单)
$rj = __DIR__ . '/robots.json';
if (is_file($rj)) {
$arr = json_decode((string) @file_get_contents($rj), true);
if (is_array($arr)) {
foreach ($arr as $item) {
if (!is_array($item) || !isset($item['名字'])) continue;
if ($item['名字'] === 'CF') {
$k['cf_sitekey'] = (string) ($item['站点密钥'] ?? '');
$k['cf_secret'] = (string) ($item['密钥'] ?? '');
} elseif ($item['名字'] === 'vaptcha') {
$k['v_vid'] = (string) ($item['VID'] ?? '');
$k['v_key'] = (string) ($item['VKey'] ?? '');
$k['v_scene'] = 0;
}
}
if ($k['cf_sitekey'] !== '' || $k['v_vid'] !== '') {
$k['source'] = 'robots.json';
}
}
}
return $cache = $k;
}
function captchaHttpPostForm($url, array $fields) {
$r = ['errno' => -1, 'status' => 0, 'body' => '', 'err' => ''];
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($fields),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$body = curl_exec($ch);
$r['status'] = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$r['err'] = curl_error($ch);
// PHP 8.0+ curl_close 为 no-op,8.5 起抛 Deprecated —— 不再调用,句柄随作用域释放
if ($body !== false) { $r['body'] = (string) $body; $r['errno'] = 0; }
return $r;
}
$ctx = stream_context_create(['http' => [
'method' => 'POST', 'timeout' => 10,
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
'content' => http_build_query($fields)]]);
$body = @file_get_contents($url, false, $ctx);
if ($body !== false) { $r['body'] = (string) $body; $r['errno'] = 0; $r['err'] = 'file_get_contents'; }
else $r['err'] = '无 curl 且请求失败';
return $r;
}
function captchaHttpPostJson($url, array $payload) {
$r = ['errno' => -1, 'status' => 0, 'body' => '', 'err' => ''];
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $json,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$body = curl_exec($ch);
$r['status'] = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$r['err'] = curl_error($ch);
// PHP 8.0+ curl_close 为 no-op,8.5 起抛 Deprecated —— 不再调用,句柄随作用域释放
if ($body !== false) { $r['body'] = (string) $body; $r['errno'] = 0; }
return $r;
}
$ctx = stream_context_create(['http' => [
'method' => 'POST', 'timeout' => 10,
'header' => "Content-Type: application/json\r\n",
'content' => $json]]);
$body = @file_get_contents($url, false, $ctx);
if ($body !== false) { $r['body'] = (string) $body; $r['errno'] = 0; $r['err'] = 'file_get_contents'; }
else $r['err'] = '无 curl 且请求失败';
return $r;
}
// ==================== 页面 ====================
$K = captchaTestKeys();
function maskKey($s) {
if ($s === '') return '(未配置)';
$len = strlen($s);
if ($len <= 10) return $s;
return substr($s, 0, 8) . str_repeat('*', max(4, $len - 12)) . substr($s, -4);
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>验证码接口测试页(Turnstile + VAPTCHA)</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; padding: 24px 14px 48px; background: #0f1420; color: #e8ecf4;
font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif; }
.wrap { max-width: 720px; margin: 0 auto; }
h1 { font-size: 20px; margin: 0 0 4px; color: #fff; }
.sub { color: #8b96b3; font-size: 13px; margin-bottom: 18px; }
.srcbar { background: #171d2c; border: 1px solid #2a3350; border-radius: 10px;
padding: 12px 16px; font-size: 13px; color: #aab4d0; line-height: 1.9;
margin-bottom: 20px; }
.srcbar b { color: #ffd479; }
.card { background: #171d2c; border: 1px solid #2a3350; border-radius: 14px;
padding: 22px 22px 20px; margin-bottom: 24px; }
.card h2 { margin: 0 0 4px; font-size: 17px; color: #fff; }
.hint { color: #8b96b3; font-size: 12.5px; margin-bottom: 14px; }
.badge { display: inline-block; padding: 3px 10px; border-radius: 999px;
font-size: 12px; margin-bottom: 12px; background: #2a3350; color: #aab4d0; }
.badge.ok { background: #1d4d2b; color: #7ee2a0; }
.badge.err { background: #4d1d1d; color: #ff9b8f; }
.badge.load { background: #4d3f1d; color: #ffd479; }
#cfBox, #vBox { min-height: 60px; display: flex; align-items: center; }
.tok { background: #10151f; border: 1px solid #2a3350; border-radius: 8px;
padding: 10px 12px; font-family: Consolas, monospace; font-size: 12px;
color: #7ee2a0; word-break: break-all; margin-top: 12px; display: none; }
.verify { background: #10151f; border: 1px solid #2a3350; border-radius: 8px;
padding: 10px 12px; font-family: Consolas, monospace; font-size: 12px;
color: #9fd489; white-space: pre-wrap; word-break: break-all;
margin-top: 10px; display: none; }
.fail { color: #ff9b8f; font-weight: bold; }
.tip { margin-top: 14px; font-size: 12px; color: #6b7695; line-height: 1.8; }
code { background: #2a3350; color: #ffd479; padding: 1px 6px; border-radius: 5px; }
</style>
</head>
<body>
<div class="wrap">
<h1>人机验证码接口测试页</h1>
<div class="sub">CF Turnstile(上)+ VAPTCHA(下)· 仅测试接口连通性,暂不接入任何登录流程</div>
<div class="srcbar">
密钥来源:<b><?php echo htmlspecialchars($K['source'], ENT_QUOTES); ?></b><br>
CF sitekey:<b><?php echo htmlspecialchars(maskKey($K['cf_sitekey']), ENT_QUOTES); ?></b>
&nbsp;|&nbsp; CF secret:<b><?php echo htmlspecialchars(maskKey($K['cf_secret']), ENT_QUOTES); ?></b><br>
VAPTCHA VID:<b><?php echo htmlspecialchars(maskKey($K['v_vid']), ENT_QUOTES); ?></b>
&nbsp;|&nbsp; Key:<b><?php echo htmlspecialchars(maskKey($K['v_key']), ENT_QUOTES); ?></b>
&nbsp;|&nbsp; scene:<b><?php echo (int) $K['v_scene']; ?></b><br>
<span style="color:#6b7695">优先级:captcha_keys.php &gt; config.php 常量 &gt; URL参数 &gt; robots.json</span>
</div>
<!-- ============ 1. Cloudflare Turnstile ============ -->
<div class="card">
<h2>1 · Cloudflare Turnstile</h2>
<div class="hint">官方文档:challenges.cloudflare.com/turnstile · 服务端校验 siteverify</div>
<div id="cfState" class="badge load">SDK 加载中…</div>
<div id="cfBox"></div>
<div id="cfTok" class="tok"></div>
<div id="cfVerify" class="verify"></div>
<div class="tip">出 token 后自动调本页 <code>action=verify&amp;provider=cf</code> 走服务端 siteverify,
展示官方接口原文。</div>
</div>
<!-- ============ 2. VAPTCHA ============ -->
<div class="card">
<h2>2 · VAPTCHA (V4)</h2>
<div class="hint">SDK:c4.vaptcha.com/src/v4.js · 服务端校验 v41.vaptcha.com/api/verify(或本地 HMAC 验签)</div>
<div id="vState" class="badge load">SDK 加载中…</div>
<div id="vBox"></div>
<div style="margin-top:10px">
<button id="vStart" disabled
style="background:#2a3350;color:#e8ecf4;border:1px solid #3b4872;border-radius:8px;padding:8px 18px;font-size:13px;cursor:not-allowed">
发起验证
</button>
</div>
<div id="vTok" class="tok"></div>
<div id="vVerify" class="verify"></div>
<div class="tip">点击「发起验证」出 token 后自动调 <code>action=verify&amp;provider=vaptcha</code>(默认官方接口,
失败可 <a href="#" id="vRetry" style="color:#7ee2a0">改用本地 HMAC 验签重试</a>)。</div>
</div>
<div class="tip" style="text-align:center">
提示:两个接口都必须在已绑定的域名下渲染(*.ypshidifu.cn / CF 站点域名),
本地 localhost 打开可能被域名校验拒绝——属正常现象,以线上域名访问为准。
</div>
</div>
<!-- ===== CF Turnstile SDK ===== -->
<?php if ($K['cf_sitekey'] !== '') : ?>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer
onerror="document.getElementById('cfState').className='badge err';document.getElementById('cfState').textContent='SDK 加载失败(网络/被墙)'"></script>
<script>
(function () {
var st = document.getElementById('cfState');
var tokBox = document.getElementById('cfTok');
var vBox = document.getElementById('cfVerify');
var sitekey = <?php echo json_encode($K['cf_sitekey']); ?>;
var tries = 0;
function ready() {
if (typeof turnstile === 'undefined') {
if (++tries > 40) { // 10 秒兜底
st.className = 'badge err';
st.textContent = 'SDK 未加载(检查网络/域名白名单)';
return;
}
return setTimeout(ready, 250);
}
st.className = 'badge load';
st.textContent = '渲染中…';
try {
turnstile.render('#cfBox', {
sitekey: sitekey,
callback: function (token) {
st.className = 'badge ok';
st.textContent = '前端验证通过,token 已获取';
tokBox.style.display = 'block';
tokBox.textContent = 'token: ' + token;
doVerify(token);
},
'error-callback': function (msg) {
st.className = 'badge err';
st.textContent = '渲染错误: ' + msg;
},
'expired-callback': function () {
st.className = 'badge load';
st.textContent = 'token 已过期,请重新勾选';
tokBox.style.display = 'none';
vBox.style.display = 'none';
}
});
} catch (e) {
st.className = 'badge err';
st.textContent = 'render 异常: ' + e.message;
}
}
function doVerify(token) {
vBox.style.display = 'block';
vBox.textContent = '服务端校验中…';
fetch('captcha_test.php?action=verify&provider=cf&token=' + encodeURIComponent(token))
.then(function (r) { return r.json(); })
.then(function (j) {
vBox.textContent = JSON.stringify(j, null, 2);
})
.catch(function (e) {
vBox.textContent = '校验请求失败: ' + e;
});
}
ready();
})();
</script>
<?php else : ?>
<script>document.getElementById('cfState').className='badge err';document.getElementById('cfState').textContent='CF sitekey 未配置';</script>
<?php endif; ?>
<!-- ===== VAPTCHA SDK ===== -->
<?php if ($K['v_vid'] !== '') : ?>
<script>
(function () {
var st = document.getElementById('vState');
var tokBox = document.getElementById('vTok');
var vBox = document.getElementById('vVerify');
var vid = <?php echo json_encode($K['v_vid']); ?>;
var scene = <?php echo (int) $K['v_scene']; ?>;
var ep = 1;
var lastResult = null;
var vaptchaObj = null;
var btn = document.getElementById('vStart');
window.__vRetryEp = function () { if (lastResult) doVerify(lastResult, ep === 1 ? 2 : 1); };
document.getElementById('vRetry').addEventListener('click', function (e) {
e.preventDefault();
window.__vRetryEp();
});
function loadScript(src, ok, fail) {
var s = document.createElement('script');
s.src = src; s.async = true;
s.onload = ok;
s.onerror = fail;
document.head.appendChild(s);
}
// VAPTCHA V4 官方 SDK(V3 的 v-cn/cdn.vaptcha.com 已 NXDOMAIN)
loadScript('https://c4.vaptcha.com/src/v4.js', init, function () {
st.className = 'badge err';
st.textContent = 'SDK 加载失败(c4.vaptcha.com 不可达)';
});
var inited = false;
function init() {
if (inited) return;
inited = true;
if (typeof vaptcha !== 'function') {
st.className = 'badge err';
st.textContent = 'SDK 已加载但 vaptcha 不是函数(版本不符)';
return;
}
st.className = 'badge load';
st.textContent = '初始化中…';
// V4:SDK 不注入按钮/事件,业务页提供入口并显式调用 validate()
vaptcha({
vid: vid,
container: '#vBox',
lang: 'zh-CN'
}).then(function (obj) {
vaptchaObj = obj;
st.className = 'badge load';
st.textContent = 'SDK 就绪,点击「发起验证」';
btn.disabled = false;
btn.style.cursor = 'pointer';
btn.addEventListener('click', function () {
st.className = 'badge load';
st.textContent = '验证中…';
Promise.resolve(obj.validate()).then(function (result) {
if (!result || !result.token) {
st.className = 'badge err';
st.textContent = '未取得 token(用户取消或验证失败)';
return;
}
lastResult = result;
ep = 1;
st.className = 'badge ok';
st.textContent = '前端验证通过,token 已获取';
tokBox.style.display = 'block';
tokBox.textContent = 'token: ' + result.token
+ '\nknock: ' + (result.knock || '')
+ '\ndfu: ' + (result.dfu || '')
+ '\nip: ' + (result.ip || '');
doVerify(result, 1);
}).catch(function (e) {
st.className = 'badge err';
st.textContent = 'validate() 异常: ' + (e && e.message ? e.message : e);
});
});
}).catch(function (e) {
st.className = 'badge err';
st.textContent = '初始化失败: ' + (e && e.message ? e.message : e);
});
}
function doVerify(result, useEp) {
ep = useEp;
vBox.style.display = 'block';
vBox.textContent = '服务端校验中(' + (ep === 1 ? '官方接口' : '本地 HMAC') + ')…';
var q = 'captcha_test.php?action=verify&provider=vaptcha&ep=' + ep
+ '&token=' + encodeURIComponent(result.token || '')
+ '&knock=' + encodeURIComponent(result.knock || '')
+ '&dfu=' + encodeURIComponent(result.dfu || '')
+ '&ip=' + encodeURIComponent(result.ip || '');
fetch(q)
.then(function (r) { return r.json(); })
.then(function (j) {
vBox.textContent = JSON.stringify(j, null, 2);
})
.catch(function (e) {
vBox.textContent = '校验请求失败: ' + e;
});
}
})();
</script>
<?php else : ?>
<script>document.getElementById('vState').className='badge err';document.getElementById('vState').textContent='VAPTCHA VID 未配置';</script>
<?php endif; ?>
</body>
</html>
-378
View File
@@ -1,378 +0,0 @@
// ===== 收银台(管理员/收银员为玩家代购)=====
// 与 admin.php 共享全局作用域,依赖 jsonApi / postApi / toast / escAdmHtml 等全局函数。
let _cashierCart = [];
let _cashierCfg = {backpack_rate:0.98, shulker_rate:1.00};
let _cashierSettlement = 'backpack';
let _cashierColor = 'purple';
let _cashierItems = [];
let _cashierCategories = [];
let _cashierTarget = '';
let _cashierTargetBalance = null;
const CASHIER_COLORS = [
{id:'purple', name:'紫色', price:0, css:'#AA00FF'},
{id:'white', name:'白色', price:2, css:'#E8E8E8'},
{id:'black', name:'黑色', price:2, css:'#2A2A2A'},
{id:'red', name:'红色', price:2, css:'#FF4444'},
{id:'blue', name:'蓝色', price:2, css:'#4488FF'},
{id:'green', name:'绿色', price:2, css:'#44FF77'},
{id:'yellow', name:'黄色', price:2, css:'#FFEE44'},
{id:'orange', name:'橙色', price:2, css:'#FF9944'}
];
function cashierMatIcon(mat) {
const m = (mat || '').toUpperCase();
if (m.indexOf('DIAMOND') >= 0) return '💎';
if (m.indexOf('IRON') >= 0) return '⛏️';
if (m.indexOf('GOLD') >= 0) return '🪙';
if (m.indexOf('EMERALD') >= 0) return '🟢';
if (m.indexOf('NETHERITE') >= 0) return '⬛';
if (m.indexOf('WOOD') >= 0 || m.indexOf('PLANK') >= 0 || m.indexOf('LOG') >= 0) return '🪵';
if (m.indexOf('STONE') >= 0 || m.indexOf('COBBLE') >= 0) return '🪨';
if (m.indexOf('APPLE') >= 0 || m.indexOf('BREAD') >= 0 || m.indexOf('MEAT') >= 0 || m.indexOf('FISH') >= 0 || m.indexOf('PORK') >= 0 || m.indexOf('BEEF') >= 0 || m.indexOf('CHICKEN') >= 0) return '🍖';
if (m.indexOf('POTION') >= 0 || m.indexOf('BOTTLE') >= 0) return '🧪';
if (m.indexOf('BOOK') >= 0 || m.indexOf('ENCHANT') >= 0) return '📕';
if (m.indexOf('SWORD') >= 0 || m.indexOf('AXE') >= 0 || m.indexOf('PICK') >= 0 || m.indexOf('SHOVEL') >= 0 || m.indexOf('HOE') >= 0) return '🗡️';
if (m.indexOf('HELMET') >= 0 || m.indexOf('CHEST') >= 0 || m.indexOf('LEGGING') >= 0 || m.indexOf('BOOT') >= 0) return '🛡️';
if (m.indexOf('BED') >= 0) return '🛏️';
if (m.indexOf('TNT') >= 0) return '🧨';
return '📦';
}
async function loadCashier(el) {
el.innerHTML = '<div class="card" style="text-align:center;padding:40px">加载中...</div>';
try { const r = await jsonApi('shop.php?action=cart_config'); if (r && r.success && r.data) _cashierCfg = r.data; } catch (e) {}
try { const r = await jsonApi('shop.php?action=list'); _cashierItems = (r && r.data) ? r.data : []; } catch (e) { _cashierItems = []; }
_cashierCategories = [...new Set(_cashierItems.map(i => i.category || '默认'))];
_cashierTarget = '';
_cashierTargetBalance = null;
_cashierCart = [];
_cashierSettlement = 'backpack';
_cashierColor = 'purple';
let catTabs = '';
if (_cashierCategories.length > 0) {
catTabs = '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px">';
_cashierCategories.forEach((cat, idx) => {
const active = idx === 0 ? 'color:#fff;background:var(--accent);border-color:var(--accent)' : '';
const cnt = _cashierItems.filter(i => (i.category || '默认') === cat).length;
catTabs += '<div class="cashier-cat-tab" data-cat="' + escAdmHtml(cat) + '" style="padding:6px 14px;border:1px solid var(--border);border-radius:16px;cursor:pointer;font-size:13px;transition:all .2s;' + active + '">' + escAdmHtml(cat) + ' <span style="opacity:.7;font-size:11px">(' + cnt + ')</span></div>';
});
catTabs += '</div>';
}
el.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;flex-wrap:wrap;gap:8px">
<h2 style="margin:0;color:var(--accent)">🧾 收银台 <span style="font-size:12px;color:var(--dim);font-weight:400">为玩家代购商品</span></h2>
</div>
<div style="display:grid;grid-template-columns:1fr 340px;gap:16px;align-items:start">
<div>
<div class="card">
<h2 style="margin-bottom:10px">① 选择目标玩家</h2>
<div class="form-row">
<input id="cashierPlayer" placeholder="输入玩家游戏名" onkeydown="if(event.key==='Enter')cashierCheckPlayer()" style="flex:1">
<button class="btn btn-blue" onclick="cashierCheckPlayer()">查询</button>
</div>
<div id="cashierTargetInfo" style="font-size:13px;color:var(--dim);margin-top:4px">输入玩家名后查询其债券余额</div>
</div>
<div class="card">
<h2 style="margin-bottom:10px">② 选择商品</h2>
${catTabs}
<div id="cashierItems" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px"></div>
</div>
</div>
<div class="card" style="position:sticky;top:12px">
<h2 style="margin-bottom:10px">🛒 代购清单</h2>
<div id="cashierCartList" style="max-height:46vh;overflow-y:auto;margin-bottom:10px"></div>
<div id="cashierSettleWrap" style="border-top:1px solid var(--border);padding-top:10px"></div>
</div>
</div>`;
document.querySelectorAll('.cashier-cat-tab').forEach(tab => {
tab.addEventListener('click', () => cashierSwitchCat(tab.dataset.cat));
});
cashierSwitchCat(_cashierCategories[0] || '');
renderCashierCart();
}
function cashierSwitchCat(cat) {
document.querySelectorAll('.cashier-cat-tab').forEach(c => {
const active = c.dataset.cat === cat;
c.style.background = active ? 'var(--accent)' : '';
c.style.color = active ? '#fff' : '';
c.style.borderColor = active ? 'var(--accent)' : '';
});
const wrap = document.getElementById('cashierItems');
if (!wrap) return;
const items = _cashierItems.filter(i => (i.category || '默认') === cat);
if (items.length === 0) { wrap.innerHTML = '<div style="color:var(--dim);font-size:13px;padding:20px;text-align:center">该分类暂无商品</div>'; return; }
let h = '';
items.forEach(i => {
const price = parseInt(i.buy_price) || 0;
const stock = parseInt(i.stock);
const stockTxt = stock > 0 ? ('库存 ' + stock) : '无限';
h += '<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:10px;display:flex;flex-direction:column;gap:6px">';
h += '<div style="display:flex;align-items:center;gap:8px"><span style="font-size:22px">' + cashierMatIcon(i.material) + '</span><span style="font-size:13px;color:var(--text);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escAdmHtml(i.display_name || i.id) + '</span></div>';
h += '<div style="font-size:12px;color:var(--dim)">' + price + ' 债券 · ' + stockTxt + '</div>';
h += '<div style="display:flex;align-items:center;gap:6px">';
h += '<button data-minus="' + i.id + '" style="width:24px;height:24px;border-radius:6px;border:1px solid var(--border);background:var(--card);color:var(--text);cursor:pointer">−</button>';
h += '<input id="cqty_' + i.id + '" value="1" style="width:42px;text-align:center;padding:4px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">';
h += '<button data-plus="' + i.id + '" style="width:24px;height:24px;border-radius:6px;border:1px solid var(--border);background:var(--card);color:var(--text);cursor:pointer">+</button>';
h += '<button data-add="' + i.id + '" style="flex:1;padding:5px;border:none;border-radius:6px;background:var(--accent);color:#fff;cursor:pointer;font-size:12px;font-weight:600">加入</button>';
h += '</div></div>';
});
wrap.innerHTML = h;
wrap.querySelectorAll('[data-minus]').forEach(b => b.addEventListener('click', () => cashierItemQty(b.dataset.minus, -1)));
wrap.querySelectorAll('[data-plus]').forEach(b => b.addEventListener('click', () => cashierItemQty(b.dataset.plus, 1)));
wrap.querySelectorAll('[data-add]').forEach(b => b.addEventListener('click', () => cashierAddItem(b.dataset.add)));
}
function cashierItemQty(id, delta) {
const inp = document.getElementById('cqty_' + id);
if (!inp) return;
let v = parseInt(inp.value) || 1;
v = Math.max(1, v + delta);
inp.value = v;
}
function cashierAddItem(id) {
const item = _cashierItems.find(i => i.id == id);
if (!item) return;
const inp = document.getElementById('cqty_' + id);
let amount = parseInt(inp ? inp.value : 1) || 1;
amount = Math.max(1, amount);
const price = parseInt(item.buy_price) || 0;
const stock = parseInt(item.stock);
const exist = _cashierCart.find(c => c.item_id == id);
if (exist) {
const max = stock > 0 ? stock : 9999;
exist.amount = Math.min(exist.amount + amount, max);
} else {
_cashierCart.push({item_id: id, name: item.display_name || item.id, price: price, material: item.material || '', stock: stock, amount: amount});
}
renderCashierCart();
toast('已加入代购清单: ' + (item.display_name || item.id), 'ok');
}
function cashierChangeQty(id, delta) {
const i = _cashierCart.find(c => c.item_id == id);
if (!i) return;
const max = i.stock > 0 ? i.stock : 9999;
i.amount = Math.min(Math.max(i.amount + delta, 1), max);
renderCashierCart();
}
function cashierRemoveItem(id) {
_cashierCart = _cashierCart.filter(c => c.item_id != id);
renderCashierCart();
}
function cashierClearCart() {
_cashierCart = [];
renderCashierCart();
}
function cashierColorFee() {
const c = CASHIER_COLORS.find(x => x.id === _cashierColor);
return c ? c.price : 0;
}
function cashierSubtotal() {
return _cashierCart.reduce((s, i) => s + i.price * i.amount, 0);
}
function cashierTotal() {
const sub = cashierSubtotal();
const rate = _cashierSettlement === 'shulker' ? _cashierCfg.shulker_rate : _cashierCfg.backpack_rate;
return Math.round(sub * rate) + (_cashierSettlement === 'shulker' ? cashierColorFee() : 0);
}
function cashierModeHtml(mode, title, total, desc) {
const sel = _cashierSettlement === mode;
return '<div onclick="cashierSelectMode(\'' + mode + '\')" style="padding:9px 12px;border:1px solid ' + (sel ? 'var(--accent)' : 'var(--border)') + ';border-radius:10px;cursor:pointer;background:' + (sel ? 'color-mix(in srgb,var(--accent) 10%,transparent)' : 'transparent') + '"><div style="display:flex;justify-content:space-between;align-items:center"><span style="font-size:13px;font-weight:600;color:var(--text)">' + title + '</span><span style="font-size:14px;font-weight:700;color:var(--accent)">' + total + ' 债券</span></div><div style="font-size:11px;color:var(--dim);margin-top:2px">' + desc + '</div></div>';
}
function renderCashierCart() {
const list = document.getElementById('cashierCartList');
const settleWrap = document.getElementById('cashierSettleWrap');
if (!list || !settleWrap) return;
if (_cashierCart.length === 0) {
list.innerHTML = '<div style="color:var(--dim);font-size:13px;padding:20px;text-align:center">清单为空,请从左侧添加商品</div>';
} else {
let h = '';
_cashierCart.forEach(i => {
h += '<div style="display:flex;gap:8px;align-items:center;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:10px;margin-bottom:6px">';
h += '<span style="font-size:18px">' + cashierMatIcon(i.material) + '</span>';
h += '<div style="flex:1;min-width:0"><div style="font-size:13px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escAdmHtml(i.name) + '</div>';
h += '<div style="font-size:11px;color:var(--dim)">' + i.price + ' 债券/个</div></div>';
h += '<div style="display:flex;align-items:center;gap:4px"><button onclick="cashierChangeQty(\'' + i.item_id + '\',-1)" style="width:22px;height:22px;border-radius:6px;border:1px solid var(--border);background:var(--card);color:var(--text);cursor:pointer">−</button>';
h += '<span style="min-width:22px;text-align:center;font-size:13px">' + i.amount + '</span>';
h += '<button onclick="cashierChangeQty(\'' + i.item_id + '\',1)" style="width:22px;height:22px;border-radius:6px;border:1px solid var(--border);background:var(--card);color:var(--text);cursor:pointer">+</button></div>';
h += '<div style="text-align:right;min-width:56px"><div style="font-size:13px;font-weight:600;color:var(--text)">' + (i.price * i.amount) + '</div><button onclick="cashierRemoveItem(\'' + i.item_id + '\')" style="background:none;border:none;color:var(--red);font-size:11px;cursor:pointer">移除</button></div>';
h += '</div>';
});
list.innerHTML = h;
}
const sub = cashierSubtotal();
const total = cashierTotal();
let html = '';
html += '<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px"><span style="color:var(--dim)">原价合计</span><span>' + sub + ' 债券</span></div>';
html += '<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:8px">';
const bpTotal = Math.round(sub * _cashierCfg.backpack_rate);
const shTotal = Math.round(sub * _cashierCfg.shulker_rate) + cashierColorFee();
html += cashierModeHtml('backpack', '🎒 塞背包', bpTotal, '享 ' + (_cashierCfg.backpack_rate * 10).toFixed(1) + ' 折');
html += cashierModeHtml('shulker', '📦 潜影盒打包', shTotal, '加价 ' + ((_cashierCfg.shulker_rate - 1) * 100).toFixed(0) + '%' + (cashierColorFee() > 0 ? (' +' + cashierColorFee() + '元') : ''));
html += '</div>';
if (_cashierSettlement === 'shulker') {
html += '<div style="margin-bottom:8px;padding:8px;border:1px solid var(--border);border-radius:8px;background:var(--bg)">';
html += '<div style="font-size:12px;color:var(--text);margin-bottom:6px">🎨 潜影盒颜色 <span style="color:var(--dim)">(紫色免费,其它+2元)</span></div>';
html += '<div style="display:flex;flex-wrap:wrap;gap:5px">';
CASHIER_COLORS.forEach(c => {
const sel = c.id === _cashierColor;
const extra = c.price > 0 ? ' (+' + c.price + ')' : '';
html += '<button onclick="cashierSelectColor(\'' + c.id + '\')" style="display:inline-flex;align-items:center;gap:4px;padding:4px 8px;border-radius:6px;cursor:pointer;font-size:11px;color:var(--text);border:1px solid ' + (sel ? 'var(--accent)' : 'var(--border)') + ';background:' + (sel ? 'color-mix(in srgb,var(--accent) 12%,transparent)' : 'transparent') + '"><span style="width:12px;height:12px;border-radius:3px;display:inline-block;background:' + c.css + ';border:1px solid rgba(255,255,255,.2)"></span>' + c.name + extra + '</button>';
});
html += '</div></div>';
}
html += '<div style="display:flex;justify-content:space-between;align-items:center;margin:8px 0"><span style="font-size:14px">预估总价</span><span id="cashierTotalLabel" style="font-size:18px;font-weight:700;color:var(--accent)">' + total + ' 债券</span></div>';
html += '<div style="display:flex;gap:8px"><button class="btn btn-yellow" style="flex:1" onclick="cashierClearCart()">清空</button><button class="btn btn-green" style="flex:2" id="cashierSettleBtn" onclick="cashierSettle()">确认代购</button></div>';
html += '<div id="cashierBalanceHint" style="font-size:11px;color:var(--dim);margin-top:6px;text-align:center"></div>';
settleWrap.innerHTML = html;
const balHint = document.getElementById('cashierBalanceHint');
if (balHint) {
if (!_cashierTarget) balHint.textContent = '未选择目标玩家';
else if (_cashierTargetBalance != null) {
balHint.textContent = '目标余额: ' + _cashierTargetBalance + ' 债券' + (_cashierTargetBalance < total ? '(余额不足!)' : '');
balHint.style.color = _cashierTargetBalance < total ? 'var(--red)' : 'var(--dim)';
}
}
}
function cashierSelectMode(mode) {
_cashierSettlement = mode;
renderCashierCart();
}
function cashierSelectColor(id) {
_cashierColor = id;
renderCashierCart();
}
async function cashierCheckPlayer() {
const inp = document.getElementById('cashierPlayer');
const info = document.getElementById('cashierTargetInfo');
if (!inp || !info) return;
const player = inp.value.trim();
if (!player) { info.textContent = '请输入玩家名'; info.style.color = 'var(--red)'; return; }
info.textContent = '查询中...';
info.style.color = 'var(--dim)';
try {
const r = await postApi('cashier_player_check', {player: player});
if (r && r.success && r.data) {
_cashierTarget = r.data.player;
_cashierTargetBalance = r.data.balance;
const online = r.data.online ? ' <span style="color:var(--green)">●在线</span>' : ' <span style="color:var(--dim)">○离线</span>';
info.innerHTML = '目标玩家: <b style="color:var(--text)">' + escAdmHtml(r.data.player) + '</b>' + online + ' 债券余额: <b style="color:var(--accent)">' + r.data.balance + '</b>';
info.style.color = 'var(--text)';
} else {
_cashierTarget = '';
_cashierTargetBalance = null;
info.textContent = '查询失败: ' + (r ? r.message : '未知错误');
info.style.color = 'var(--red)';
}
} catch (e) {
_cashierTarget = '';
_cashierTargetBalance = null;
info.textContent = '查询失败: ' + e.message;
info.style.color = 'var(--red)';
}
renderCashierCart();
}
async function cashierSettle() {
if (_cashierCart.length === 0) { toast('代购清单为空', 'err'); return; }
if (!_cashierTarget) { toast('请先选择并查询目标玩家', 'err'); return; }
const total = cashierTotal();
const sub = cashierSubtotal();
if (_cashierTargetBalance != null && _cashierTargetBalance < total) {
toast('目标玩家余额不足(' + _cashierTargetBalance + ' < ' + total + ')', 'err');
return;
}
const lines = _cashierCart.map(i => '· ' + i.name + ' x' + i.amount + ' = ' + (i.price * i.amount) + ' 债券').join('\n');
const modeName = _cashierSettlement === 'shulker' ? ('潜影盒打包(' + (CASHIER_COLORS.find(c => c.id === _cashierColor) ? CASHIER_COLORS.find(c => c.id === _cashierColor).name : '') + ')') : '塞背包';
const summary = '目标玩家: ' + _cashierTarget + '\n结算方式: ' + modeName + '\n原价: ' + sub + ' 债券\n预估总价: ' + total + ' 债券\n\n' + lines;
cashierShowConfirm(total, summary);
}
function cashierShowConfirm(total, summary) {
const needPwd = total > 1000;
let overlay = document.getElementById('cashierConfirmOverlay');
if (overlay) overlay.remove();
overlay = document.createElement('div');
overlay.id = 'cashierConfirmOverlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);z-index:6000;display:flex;justify-content:center;align-items:center;animation:glassFadeIn .25s ease';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
let html = '<div style="width:420px;max-width:92%;max-height:88vh;overflow-y:auto;background:var(--card);border:1px solid rgba(88,166,255,0.25);border-radius:16px;padding:24px;box-shadow:0 12px 48px rgba(0,0,0,0.5)">';
html += '<h3 style="margin:0 0 12px;color:var(--accent)">🧾 确认代购</h3>';
html += '<pre style="white-space:pre-wrap;word-break:break-word;font-family:inherit;font-size:13px;color:var(--text);background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px;margin:0 0 14px;line-height:1.6">' + escAdmHtml(summary) + '</pre>';
if (needPwd) {
html += '<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:4px">⚠️ 金额≥1000债券,需输入管理员密码确认</label>';
html += '<input id="cashierPwd" type="password" placeholder="管理员密码" style="width:100%;padding:9px 12px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;margin-bottom:12px" onkeydown="if(event.key===\'Enter\')cashierConfirmBuy()">';
}
html += '<div style="display:flex;gap:10px"><button class="btn btn-yellow" style="flex:1" onclick="document.getElementById(\'cashierConfirmOverlay\').remove()">取消</button><button class="btn btn-green" style="flex:2" id="cashierConfirmBtn" onclick="cashierConfirmBuy()">确认代购</button></div>';
html += '</div>';
overlay.innerHTML = html;
document.body.appendChild(overlay);
if (needPwd) { const p = document.getElementById('cashierPwd'); if (p) p.focus(); }
}
async function cashierConfirmBuy() {
const total = cashierTotal();
const pwdEl = document.getElementById('cashierPwd');
const password = pwdEl ? pwdEl.value : '';
if (total > 1000 && !password) { toast('请输入管理员密码确认', 'err'); if (pwdEl) pwdEl.focus(); return; }
const btn = document.getElementById('cashierConfirmBtn');
if (btn) { btn.disabled = true; btn.textContent = '代购中...'; }
await cashierDoBuy(total, password);
const ov = document.getElementById('cashierConfirmOverlay');
if (ov) ov.remove();
}
async function cashierDoBuy(total, password) {
try {
// 1) 获取管理员代购令牌(all 权限,单次使用)
const tR = await postApi('gen_token', {player: _cashierTarget, purpose: 'all'});
if (!tR || !tR.success || !tR.data || !tR.data.token) {
toast('获取代购令牌失败', 'err');
return;
}
const token = tR.data.token;
const items = _cashierCart.map(i => ({item_id: i.item_id, amount: i.amount}));
const body = {
token: token,
items: JSON.stringify(items),
settlement: _cashierSettlement,
player: _cashierTarget,
password: password || undefined
};
if (_cashierSettlement === 'shulker') body.shulker_color = _cashierColor;
const res = await fetch('api/shop.php?action=buy_cart', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
const data = await res.json();
if (data && data.need_password) {
toast('请重新输入密码确认', 'err');
cashierShowConfirm(total, '目标玩家: ' + _cashierTarget + '\n(需密码确认)');
return;
}
if (data && data.success) {
toast('代购成功:' + _cashierTarget + ' 已获得商品', 'ok');
_cashierCart = [];
renderCashierCart();
cashierCheckPlayer();
} else {
toast('代购失败: ' + (data ? data.message : '未知错误'), 'err');
}
} catch (e) {
toast('代购异常: ' + e.message, 'err');
}
}
-798
View File
@@ -1,798 +0,0 @@
<?php
/**
* 独立收银台页面
* 支持管理员登录或收银员登录;具备商品选择、购物车、手动折扣(服务端强制上限)、订单历史。
*/
require_once __DIR__ . '/core.php';
if (session_status() === PHP_SESSION_NONE) session_start();
// 读取收银台配置(★ 单次查询同时获取packmoney、green_discount和费率)
function cashierGetConfig() {
try {
$db = getDB();
$stmt = $db->prepare("SELECT cfg_key, cfg_value FROM shop_config WHERE cfg_key IN ('packmoney','green_discount','cart_backpack_rate','cart_shulker_rate')");
$result = $stmt->execute();
$map = ['packmoney' => 5, 'green_discount' => 10, 'cart_backpack_rate' => '0.98', 'cart_shulker_rate' => '1.00']; // 默认值
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$map[$row['cfg_key']] = $row['cfg_value'];
}
return $map;
} catch (Exception $e) {
return ['packmoney' => 5, 'green_discount' => 10, 'cart_backpack_rate' => '0.98', 'cart_shulker_rate' => '1.00'];
}
}
$cfg = cashierGetConfig();
$packMoney = (float)$cfg['packmoney'];
$greenDiscount = (float)$cfg['green_discount'];
$backpackRate = (float)($cfg['cart_backpack_rate'] ?? 0.98);
$shulkerRate = (float)($cfg['cart_shulker_rate'] ?? 1.00);
$loggedIn = isAdminLoggedIn() || isCashierLoggedIn();
$initialRole = 'guest';
$initialName = '';
$initialLimit = 0;
$initialCanCash = 0; // ★ 默认:游客(未登录)无现金收款权限,避免下方 json_encode 报未定义变量
if (isAdminLoggedIn()) {
$initialRole = 'admin';
$initialName = 'admin';
$initialLimit = 100;
$initialCanCash = 1; // 管理员始终可进行现金收款
} elseif (isCashierLoggedIn()) {
$c = getCurrentCashier();
$initialRole = 'cashier';
$initialName = $c['username'];
$initialLimit = $c['discount_limit_percent'];
$initialCanCash = (int)($c['can_cash'] ?? 0);
}
$initialState = json_encode([
'logged_in' => $loggedIn,
'role' => $initialRole,
'username' => $initialName,
'discount_limit' => $initialLimit,
'cashier_can_cash' => $initialCanCash,
'packmoney' => $packMoney,
'green_discount' => $greenDiscount,
'backpack_rate' => $backpackRate,
'shulker_rate' => $shulkerRate
], 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, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http="Expires" content="0">
<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; transition:background .25s ease; }
.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); letter-spacing:.5px; }
.header .who { font-size:13px; color:var(--dim); }
.header .who b { color:var(--text); }
.tag { display:inline-block; padding:2px 10px; border-radius:999px; font-size:11px; font-weight:600; line-height:1.5; vertical-align:middle; }
.tag-admin { background:rgba(188,140,255,.15); color:var(--purple); border:1px solid rgba(188,140,255,.35); }
.tag-cashier { background:rgba(88,166,255,.15); color:var(--accent); border:1px solid rgba(88,166,255,.35); }
.btn { padding:8px 16px; border:none; border-radius:6px; cursor:pointer; font-size:13px; font-weight:600; transition:all .2s cubic-bezier(.16,1,.3,1); }
.btn-blue { background:var(--accent); color:#fff; }
.btn-green { background:var(--green); color:#fff; }
.btn-red { background:var(--red); color:#fff; }
.btn-yellow { background:var(--yellow); color:#000; }
.btn-ghost { background:transparent; border:1px solid var(--border); color:var(--dim); }
.btn:hover { opacity:.88; transform:translateY(-1px); box-shadow:0 4px 14px rgba(0,0,0,.35); }
.btn:active { transform:translateY(0); }
.btn:disabled { opacity:.4; cursor:not-allowed; transform:none; box-shadow:none; }
/* 登录页 */
.login-wrap { min-height:100vh; display:flex; align-items:center; justify-content:center; padding:20px;
background:radial-gradient(1200px 600px at 50% -10%, rgba(88,166,255,.12), transparent); }
.login-card { background:var(--card); border:1px solid var(--border); border-radius:16px; padding:36px 32px; width:360px; max-width:100%; box-shadow:0 20px 60px rgba(0,0,0,.5); }
.login-card h2 { text-align:center; margin-bottom:6px; color:var(--accent); font-size:22px; }
.login-card .sub { text-align:center; color:var(--dim); font-size:12px; margin-bottom:24px; }
.login-card label { display:block; font-size:12px; color:var(--dim); margin:14px 0 6px; }
.login-card input { width:100%; padding:11px 12px; background:var(--bg); border:1px solid var(--border); border-radius:8px; color:var(--text); font-size:14px; outline:none; transition:border .2s; }
.login-card input:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(88,166,255,.15); }
.login-card .btn { width:100%; margin-top:22px; padding:12px; font-size:15px; }
/* 主界面 */
.app { display:grid; grid-template-columns: 380px 1fr; gap:16px; padding:16px; align-items:start; }
@media(max-width:900px){ .app { grid-template-columns:1fr; } }
.panel { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:16px; }
.panel h3 { font-size:14px; color:var(--accent); margin-bottom:12px; display:flex; align-items:center; gap:8px; }
.field { margin-bottom:12px; }
.field label { font-size:12px; color:var(--dim); display:block; margin-bottom:5px; }
.field input, .field select { width:100%; padding:9px 11px; background:var(--bg); border:1px solid var(--border); border-radius:7px; color:var(--text); font-size:13px; outline:none; }
.field input:focus, .field select:focus { border-color:var(--accent); }
.player-bar { display:flex; gap:8px; }
.player-bar input { flex:1; }
.balances { display:flex; gap:10px; margin:12px 0; }
.balances .b { flex:1; background:var(--bg); border:1px solid var(--border); border-radius:8px; padding:10px; text-align:center; }
.balances .b .v { font-size:20px; font-weight:700; color:var(--green); }
.balances .b .l { font-size:11px; color:var(--dim); margin-top:3px; }
.online-dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:5px; }
.online-dot.on { background:var(--green); box-shadow:0 0 8px var(--green); }
.online-dot.off { background:var(--dim); }
/* 统一主界面输入框/下拉框样式:修复深色主题下浏览器默认浅色控件"样式不搭"与布局错位问题 */
.app input, .app select, .app textarea {
padding:9px 11px; background:var(--bg); border:1px solid var(--border);
border-radius:7px; color:var(--text); font-size:13px; outline:none;
font-family:inherit; transition:border .2s, box-shadow .2s;
}
.app select { width:100%; }
.app input:focus, .app select:focus, .app textarea:focus {
border-color:var(--accent); box-shadow:0 0 0 3px rgba(88,166,255,.12);
}
.player-bar input { flex:1; }
.catalog-head input { flex:1; }
.cart-item { display:flex; align-items:center; gap:10px; padding:10px; background:var(--bg); border:1px solid var(--border); border-radius:9px; margin-bottom:8px; transition:all .2s; }
.cart-item:hover { border-color:var(--accent); }
.cart-item .ci-name { flex:1; font-size:13px; }
.cart-item .ci-name small { color:var(--dim); display:block; font-size:11px; }
.qty { display:flex; align-items:center; gap:6px; }
.qty button { width:26px; height:26px; border-radius:6px; border:1px solid var(--border); background:var(--card); color:var(--text); cursor:pointer; font-size:15px; line-height:1; }
.qty input { width:46px; text-align:center; padding:5px; background:var(--bg); border:1px solid var(--border); border-radius:6px; color:var(--text); font-size:13px; }
.ci-del { color:var(--red); cursor:pointer; font-size:16px; padding:4px; }
.settlement-row { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px; }
.seg { padding:8px 14px; border:1px solid var(--border); border-radius:8px; cursor:pointer; font-size:12px; color:var(--dim); background:var(--bg); transition:all .2s; }
.seg.active { background:var(--accent); color:#fff; border-color:var(--accent); }
.summary-line { display:flex; justify-content:space-between; font-size:13px; padding:6px 0; border-bottom:1px dashed var(--border); }
.summary-line.total { font-size:17px; font-weight:700; border-bottom:none; margin-top:8px; }
.summary-line.total .v { color:var(--green); }
.summary-line .disc { color:var(--yellow); }
.catalog-head { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; }
.cat-tabs { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:12px; }
.prod-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:10px; max-height:62vh; overflow-y:auto; padding-right:4px; }
.prod { background:var(--bg); border:1px solid var(--border); border-radius:10px; padding:12px; cursor:pointer; transition:all .2s cubic-bezier(.16,1,.3,1); position:relative; overflow:hidden; }
.prod:hover { transform:translateY(-3px); border-color:var(--accent); box-shadow:0 8px 24px rgba(0,0,0,.4); }
.prod .pname { font-size:13px; font-weight:600; margin-bottom:4px; }
.prod .pcat { font-size:10px; color:var(--dim); margin-bottom:8px; }
.prod .pprice { font-size:15px; color:var(--accent); font-weight:700; }
.prod .pstock { font-size:10px; color:var(--dim); position:absolute; top:10px; right:10px; }
.prod.soldout { opacity:.45; cursor:not-allowed; }
.prod.soldout:hover { transform:none; border-color:var(--border); box-shadow:none; }
.toast { position:fixed; top:16px; right:16px; padding:11px 18px; border-radius:8px; font-size:13px; z-index:200; animation:sIn .3s; box-shadow:0 8px 24px rgba(0,0,0,.4); }
.toast.ok { background:var(--green); color:#fff; }
.toast.err { background:var(--red); color:#fff; }
@keyframes sIn { from{transform:translateX(110%);opacity:0} to{transform:translateX(0);opacity:1} }
.orders { margin-top:14px; }
.order-row { font-size:12px; padding:10px; background:var(--bg); border:1px solid var(--border); border-radius:8px; margin-bottom:7px; }
.order-row .ot { display:flex; justify-content:space-between; }
.order-row .ono { color:var(--accent); font-weight:600; }
.order-row .od { color:var(--dim); margin-top:4px; }
.order-row .od .disc { color:var(--yellow); }
.theme-picker { display:flex; gap:8px; align-items:center; margin-top:8px; }
.color-btn { width:28px; height:28px; border-radius:4px; cursor:pointer; border:2px solid transparent; transition:all .2s; }
.color-btn:hover { border-color:var(--accent); transform:scale(1.12); }
.glass-alert-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.45);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);z-index:5000;justify-content:center;align-items:center;animation:glassFadeIn .25s ease}
.glass-alert-overlay.show{display:flex}
.glass-alert-card{background:rgba(22,27,34,0.94);backdrop-filter:blur(24px);-webkit-backdrop-filter:blur(24px);border:1px solid rgba(88,166,255,0.25);border-radius:16px;padding:26px;width:340px;max-width:88%;box-shadow:0 12px 48px rgba(0,0,0,0.5),inset 0 1px 0 rgba(255,255,255,0.05);animation:glassSlideUp .3s ease;text-align:center}
.glass-alert-card .alert-icon{font-size:34px;margin-bottom:10px}
.glass-alert-card .alert-msg{font-size:14px;color:#e6edf3;line-height:1.6;margin-bottom:18px;word-break:break-word}
.glass-alert-card .alert-input{width:100%;padding:10px 12px;background:rgba(13,17,23,0.8);border:1px solid rgba(88,166,255,0.3);border-radius:8px;color:#e6edf3;font-size:13px;outline:none;box-sizing:border-box;text-align:center;margin-bottom:16px}
.glass-alert-card .alert-btns{display:flex;gap:10px;justify-content:center}
.glass-alert-card .alert-btns button{padding:8px 24px;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;transition:all .2s}
.glass-alert-card .alert-btns .ag-ok{background:#58a6ff;color:#fff}
.glass-alert-card .alert-btns .ag-cancel{background:rgba(255,255,255,0.08);color:#8b949e}
@keyframes glassFadeIn{from{opacity:0}to{opacity:1}}
@keyframes glassSlideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
.hidden { display:none !important; }
/* 自定义滚动条:统一深色质感,避免浏览器默认浅色滚动条破坏整体观感 */
*::-webkit-scrollbar { width:10px; height:10px; }
*::-webkit-scrollbar-track { background:transparent; }
*::-webkit-scrollbar-thumb { background:var(--border); border-radius:6px; }
*::-webkit-scrollbar-thumb:hover { background:var(--dim); }
* { scrollbar-width:thin; scrollbar-color:var(--border) transparent; }
/* 小票(打包收据)样式:模拟热敏纸小票 */
#receiptOverlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,.55); z-index:6000; justify-content:center; align-items:flex-start; padding:24px 12px; overflow-y:auto; }
#receiptOverlay.show { display:flex; }
.receipt { background:#fdfdf7; color:#1a1a1a; width:340px; max-width:100%; border-radius:8px; padding:22px 20px; box-shadow:0 16px 48px rgba(0,0,0,.5); font-family:'Courier New',monospace; font-size:13px; line-height:1.5; }
.receipt h2 { text-align:center; font-size:17px; margin-bottom:4px; letter-spacing:1px; }
.receipt .r-sub { text-align:center; font-size:11px; color:#555; margin-bottom:10px; }
.receipt .r-line { display:flex; justify-content:space-between; gap:8px; }
.receipt .r-sep { border-top:1px dashed #999; margin:8px 0; }
.receipt .r-item { font-size:12px; }
.receipt .r-item .ri-name { flex:1; }
.receipt .r-total { font-size:16px; font-weight:700; border-top:2px solid #1a1a1a; margin-top:8px; padding-top:6px; }
.receipt .r-foot { text-align:center; font-size:10px; color:#666; margin-top:12px; }
.receipt .r-actions { display:flex; gap:10px; margin-top:16px; }
.receipt .r-actions button { flex:1; padding:10px; border:none; border-radius:6px; font-size:13px; font-weight:700; cursor:pointer; }
.receipt .r-print { background:#1a1a1a; color:#fff; }
.receipt .r-done { background:#d9d9cf; color:#1a1a1a; }
/* 打印时只显示小票本体 */
@media print {
body * { visibility:hidden !important; }
#receiptOverlay, #receiptOverlay * { visibility:visible !important; }
#receiptOverlay { position:absolute; background:#fff; padding:0; align-items:flex-start; }
.receipt { box-shadow:none; width:80mm; max-width:80mm; margin:0 auto; }
.receipt .r-actions { display:none !important; }
}
</style>
</head>
<body>
<?php if (!$loggedIn): ?>
<!-- 登录页 -->
<div class="login-wrap" id="loginWrap">
<div class="login-card">
<h2>🧾 SDF1 收银台</h2>
<div class="sub">管理员或收银员登录</div>
<form id="loginForm" onsubmit="return doLogin(event)">
<label>账号</label>
<input type="text" id="loginUser" autocomplete="username" placeholder="收银员账号 / admin" required>
<label>密码</label>
<input type="password" id="loginPass" autocomplete="current-password" placeholder="登录密码" required>
<button type="submit" class="btn btn-blue" id="loginBtn">登 录</button>
</form>
</div>
</div>
<?php else: ?>
<!-- 主界面 -->
<div class="header">
<div style="display:flex;align-items:center;gap:12px">
<h1>🧾 SDF1 收银台</h1>
<span class="who">操作员:<b id="whoName"></b> <span id="whoRole" class="tag tag-admin"></span></span>
</div>
<div style="display:flex;align-items:center;gap:10px">
<button class="btn btn-yellow" onclick="showThemePicker()">🎨 主题</button>
<button class="btn btn-red" onclick="doLogout()">退出</button>
</div>
</div>
<div class="app">
<!-- 左:玩家 + 购物车 -->
<div>
<div class="panel">
<h3>👤 目标玩家</h3>
<div class="player-bar">
<input type="text" id="playerName" placeholder="输入玩家名" onkeydown="if(event.key==='Enter')checkPlayer()">
<button class="btn btn-blue" onclick="checkPlayer()">查询</button>
</div>
<div class="balances">
<div class="b"><div class="v" id="balVal">-</div><div class="l">债券余额</div></div>
<div class="b"><div class="v" style="font-size:13px;padding-top:5px"><span class="online-dot off" id="onlineDot"></span><span id="onlineTxt">未知</span></div><div class="l">在线状态</div></div>
</div>
</div>
<div class="panel" style="margin-top:16px">
<h3>🛒 购物车</h3>
<div id="cartList"><div style="color:var(--dim);font-size:12px;text-align:center;padding:18px">点击右侧商品加入购物车</div></div>
<div style="margin-top:12px">
<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:6px">结算方式</label>
<div class="settlement-row">
<div class="seg active" data-set="backpack" onclick="setSettlement('backpack')">🎒 塞背包 (<?= ($greenDiscount < 10) ? $greenDiscount : round($backpackRate * 100) ?>折)</div>
<div class="seg" data-set="shulker" onclick="setSettlement('shulker')">📦 潜影盒(原色免费)</div>
</div>
<!-- ★ 收款模式切换(管理员可用 / 收银员看权限) -->
<div id="payModeWrap" style="margin-top:10px">
<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:6px">收款模式</label>
<div class="settlement-row" id="payModeRow">
<div class="seg active" data-pm="bond" onclick="setPayMode('bond')">💰 债券收款(扣余额)</div>
<div class="seg" data-pm="cash" onclick="setPayMode('cash')" id="cashModeBtn">💵 现金收款(仅记账)</div>
</div>
</div>
<div id="shulkerColors" class="hidden" style="margin-bottom:10px">
<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:6px">潜影盒颜色(原色免费,其它颜色+2元)</label>
<select id="shulkerColor" onchange="recalc()">
<option value="default">原色(免费)</option>
<option value="white">白色</option>
<option value="black">黑色</option>
<option value="red">红色</option>
<option value="blue">蓝色</option>
<option value="green">绿色</option>
<option value="yellow">黄色</option>
<option value="orange">橙色</option>
</select>
</div>
<label style="font-size:12px;color:var(--dim);display:block;margin-bottom:6px">手动折扣(%)— 上限 <b id="discLimit">0</b>%</label>
<div style="display:flex;gap:8px;align-items:center">
<input type="number" id="discount" min="0" value="0" oninput="recalc()" style="flex:1">
<span style="font-size:12px;color:var(--dim)">off</span>
</div>
<div id="summary" style="margin-top:14px"></div>
<button class="btn btn-green" id="payBtn" style="width:100%;margin-top:14px;padding:12px;font-size:15px" onclick="confirmPay()" disabled>确认收款</button>
<div id="adminPwWrap" class="field hidden" style="margin-top:12px">
<label>管理员密码(≥1000债券需确认)</label>
<input type="password" id="adminPw" placeholder="输入管理员密码">
</div>
</div>
</div>
</div>
<!-- 右:商品目录 -->
<div class="panel">
<h3>🛍️ 商品目录</h3>
<div class="catalog-head">
<input type="text" id="prodSearch" placeholder="搜索商品名…" oninput="renderCatalog()" style="flex:1">
</div>
<div class="cat-tabs" id="catTabs"></div>
<div class="prod-grid" id="prodGrid"><div style="color:var(--dim);font-size:12px;padding:18px">加载中…</div></div>
<!-- 订单记录改为独立入口(点击跳转到 orders.php) -->
<div class="orders" style="margin-top:14px">
<h3 style="margin-top:6px;display:flex;align-items:center;justify-content:space-between">
<span>📜 订单记录</span>
<a href="orders.php" id="orderLinkBtn" class="btn btn-ghost" style="padding:4px 14px;font-size:11px;text-decoration:none">查看全部 →</a>
</h3>
<div id="orderList"><div style="color:var(--dim);font-size:12px;padding:10px">加载中…</div></div>
</div>
</div>
</div>
<?php endif; ?>
<!-- 小票(打包收据)弹窗 -->
<div id="receiptOverlay">
<div class="receipt" id="receiptBody"><!-- 由 JS 填充 --></div>
</div>
<div id="toastHost"></div>
<script>
const STATE = <?php echo $initialState; ?>;
// 动态更新打包费提示(与后端 packmoney 配置一致,避免硬编码 +2 元)
(function() {
const lbl = document.querySelector('#shulkerColors label');
if (lbl) lbl.textContent = '潜影盒颜色(原色免费,其它颜色+ ' + (STATE.packmoney || 2) + ' 元打包费)';
})();
let products = [];
let categories = [];
let cart = {}; // id -> qty
let settlement = 'backpack';
let payMode = 'bond'; // bond=债券收款(扣余额), cash=现金收款(仅记账)
let currentPlayer = null;
let playerBalance = 0;
// ===== 工具 =====
function toast(msg, type) {
const t = document.createElement('div');
t.className = 'toast ' + (type || 'ok');
t.textContent = msg;
document.getElementById('toastHost').appendChild(t);
setTimeout(() => t.remove(), 2600);
}
async function api(url, opts, timeoutMs = 25000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const r = await fetch(url, Object.assign({headers:{'X-Requested-With':'cashier'}}, opts, {signal: ctrl.signal}));
let data;
try { data = await r.json(); } catch(e) { throw new Error('服务器响应异常 (HTTP ' + r.status + ')'); }
return data;
} catch(e) {
if (e.name === 'AbortError') throw new Error('请求超时(服务器繁忙),请稍后重试');
throw e;
} finally {
clearTimeout(timer);
}
}
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
// ===== 主题 =====
function showThemePicker() {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);display:flex;justify-content:center;align-items:center;z-index:2000';
overlay.innerHTML = `<div style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:24px;width:460px;max-width:90%">
<h3 style="margin-bottom:12px">🎨 选择背景颜色</h3>
<div class="theme-picker">
<div class="color-btn" style="background:#0d1117" onclick="setTheme('#0d1117')"></div>
<div class="color-btn" style="background:#1a1e2e" onclick="setTheme('#1a1e2e')"></div>
<div class="color-btn" style="background:#0f4c75" onclick="setTheme('#0f4c75')"></div>
<div class="color-btn" style="background:#1b2631" onclick="setTheme('#1b2631')"></div>
<div class="color-btn" style="background:#2c3e50" onclick="setTheme('#2c3e50')"></div>
<div class="color-btn" style="background:#23272a" onclick="setTheme('#23272a')"></div>
</div>
<div style="margin-top:16px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="text" id="customColor" placeholder="#1a237e" class="color-input" style="flex:1;min-width:120px;padding:7px 10px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:13px">
<button class="btn btn-blue" onclick="applyCustomColor()">应用</button>
</div>
<div style="text-align:right;margin-top:14px"><button class="btn btn-ghost" onclick="this.closest('div').parentElement.remove()">关闭</button></div>
</div>`;
document.body.appendChild(overlay);
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
}
function setTheme(color) {
document.documentElement.style.setProperty('--bg', color);
document.body.style.background = color;
localStorage.setItem('sdf1_cashier_theme', color);
toast('主题已应用', 'ok');
}
function applyCustomColor() {
const v = document.getElementById('customColor').value.trim();
if (/^#?[0-9A-Fa-f]{6}$/.test(v)) { setTheme(v[0]==='#'?v:'#'+v); document.querySelector('.theme-picker')?.closest('div').parentElement.remove(); }
else toast('请输入有效的十六进制颜色', 'err');
}
// ===== 登录/登出 =====
async function doLogin(e) {
e.preventDefault();
const btn = document.getElementById('loginBtn');
btn.disabled = true;
try {
const data = await api('api/cashier.php?action=login', {
method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:'username=' + encodeURIComponent(document.getElementById('loginUser').value) + '&password=' + encodeURIComponent(document.getElementById('loginPass').value)
});
if (data.success) { location.reload(); }
else { toast(data.message || '登录失败', 'err'); btn.disabled = false; }
} catch(err) { toast(err.message, 'err'); btn.disabled = false; }
return false;
}
async function doLogout() {
await api('api/cashier.php?action=logout');
location.reload();
}
// ===== 初始化(已登录)=====
async function initApp() {
document.getElementById('whoName').textContent = STATE.username;
const roleEl = document.getElementById('whoRole');
roleEl.textContent = STATE.role === 'admin' ? '管理员' : '收银员';
roleEl.className = 'tag ' + (STATE.role === 'admin' ? 'tag-admin' : 'tag-cashier');
document.getElementById('discLimit').textContent = STATE.discount_limit;
// 管理员需密码确认大额
if (STATE.role === 'admin') document.getElementById('adminPwWrap').classList.remove('hidden');
// ★ 收款模式权限控制
const cashModeBtn = document.getElementById('cashModeBtn');
if (STATE.role === 'admin') {
// 管理员始终可用现金模式
cashModeBtn.style.display = '';
document.getElementById('payModeWrap').style.display = '';
} else if (STATE.cashier_can_cash) {
// 收银员:管理员授权了现金收款权限
cashModeBtn.style.display = '';
document.getElementById('payModeWrap').style.display = '';
} else {
// 收银员无现金权限 → 隐藏现金按钮,锁定债券模式
cashModeBtn.style.display = 'none';
// 不隐藏整个 payModeWrap,但只显示债券模式
}
await Promise.all([loadProducts(), loadOrders()]); // 底部保留 5 条最新订单概览;完整记录见 orders.php
}
async function loadProducts() {
try {
const d = await api('api/shop.php?action=list');
products = (d.data && Array.isArray(d.data)) ? d.data : [];
const dc = await api('api/shop.php?action=categories');
categories = (dc.data && Array.isArray(dc.data)) ? dc.data : [];
buildCatTabs();
renderCatalog();
} catch(e) { toast('商品加载失败: ' + e.message, 'err'); }
}
function buildCatTabs() {
const wrap = document.getElementById('catTabs');
let html = '<div class="seg active" data-cat="" onclick="setCat(\'\')">全部</div>';
categories.forEach(c => {
html += '<div class="seg" data-cat="'+esc(c.category)+'" onclick="setCat(\''+esc(c.category)+'\')">'+esc(c.category)+'</div>';
});
wrap.innerHTML = html;
}
let curCat = '';
function setCat(cat) {
curCat = cat;
document.querySelectorAll('#catTabs .seg').forEach(s => s.classList.toggle('active', s.dataset.cat === cat));
renderCatalog();
}
function renderCatalog() {
const q = (document.getElementById('prodSearch').value || '').toLowerCase();
const grid = document.getElementById('prodGrid');
const list = products.filter(p => {
if (curCat && p.category !== curCat) return false;
if (q && !(p.display_name||'').toLowerCase().includes(q)) return false;
return true;
});
if (!list.length) { grid.innerHTML = '<div style="color:var(--dim);font-size:12px;padding:18px">无匹配商品</div>'; return; }
grid.innerHTML = list.map(p => {
const sold = (p.stock === 0);
return `<div class="prod ${sold?'soldout':''}" ${sold?'':`onclick="addToCart('${esc(p.id)}')"`}>
<div class="pstock">${sold?'售罄':('库存'+esc(p.stock))}</div>
<div class="pname">${esc(p.display_name)}</div>
<div class="pcat">${esc(p.category||'')}</div>
<div class="pprice">${esc(p.buy_price)} 债券</div>
</div>`;
}).join('');
}
function addToCart(id) {
cart[id] = (cart[id] || 0) + 1;
renderCart(); recalc();
}
function changeQty(id, d) {
cart[id] = (cart[id] || 0) + d;
if (cart[id] <= 0) delete cart[id];
renderCart(); recalc();
}
function removeFromCart(id) { delete cart[id]; renderCart(); recalc(); }
function renderCart() {
const el = document.getElementById('cartList');
const ids = Object.keys(cart);
if (!ids.length) { el.innerHTML = '<div style="color:var(--dim);font-size:12px;text-align:center;padding:18px">点击右侧商品加入购物车</div>'; return; }
el.innerHTML = ids.map(id => {
const p = products.find(x => String(x.id) === String(id));
if (!p) return '';
return `<div class="cart-item">
<div class="ci-name">${esc(p.display_name)}<small>${esc(p.buy_price)} 债券/个</small></div>
<div class="qty">
<button onclick="changeQty('${esc(id)}',-1)">−</button>
<input type="number" min="1" value="${cart[id]}" onchange="cart['${esc(id)}']=Math.max(1,parseInt(this.value)||1);renderCart();recalc()">
<button onclick="changeQty('${esc(id)}',1)">+</button>
</div>
<span class="ci-del" onclick="removeFromCart('${esc(id)}')">✕</span>
</div>`;
}).join('');
}
function setSettlement(s) {
settlement = s;
document.querySelectorAll('.settlement-row .seg').forEach(x => x.classList.toggle('active', x.dataset.set === s));
document.getElementById('shulkerColors').classList.toggle('hidden', s !== 'shulker');
recalc();
}
// ★ 收款模式切换
function setPayMode(pm) {
payMode = pm;
document.querySelectorAll('#payModeRow .seg').forEach(x => x.classList.toggle('active', x.dataset.pm === pm));
// 现金模式下提示"仅记账不扣债券"
if (pm === 'cash') {
toast('现金收款模式:仅记账,不扣除玩家债券', 'ok');
}
recalc();
}
// ===== 计算 + 预览 =====
// ★ 费率从后端配置读取,不再硬编码
// 背包(环保单)费率:尊重游戏内 green_discount 设置。
// green_discount=10(未设置游戏折扣)→ 用 cart_backpack_rate(默认0.98=98折);
// green_discount<10(如9=9折)→ 以游戏折扣为准(×0.9),覆盖默认98折。
// 与后端 api/shop.php buy_cart 计算保持一致,避免"界面显示98折、实付却是更低折"。
const backpackRate = (STATE.green_discount != null && STATE.green_discount < 10)
? (STATE.green_discount / 10)
: (STATE.backpack_rate || 0.98);
const RATES = { backpack: backpackRate, shulker: (STATE.shulker_rate || 1.00) };
async function recalc() {
const ids = Object.keys(cart);
const payBtn = document.getElementById('payBtn');
if (!ids.length || !currentPlayer) {
document.getElementById('summary').innerHTML = '';
payBtn.disabled = true;
return;
}
let subtotal = 0;
ids.forEach(id => {
const p = products.find(x => String(x.id) === String(id));
if (p) subtotal += (parseInt(p.buy_price)||0) * cart[id];
});
const rate = RATES[settlement];
// ★ rate 已包含背包/潜影盒费率;背包模式下若游戏设置了 green_discount(<10),
// 该折扣已并入 rate(与后端 buy_cart 一致),避免重复打折。
let afterRate = Math.round(subtotal * rate);
let ecoPct = 0;
let ecoAmt = 0;
// 仅在背包模式且游戏设置了绿色折扣时,展示"环保单 X折"提示行(rate 已含折扣,此处仅展示节省额)
if (settlement === 'backpack' && STATE.green_discount != null && STATE.green_discount < 10) {
ecoPct = STATE.green_discount; // 折数(如 9)
ecoAmt = subtotal - afterRate; // 折扣节省额(仅展示用)
}
let colorFee = 0;
const sc = document.getElementById('shulkerColor').value;
if (settlement === 'shulker' && sc !== 'default' && sc !== 'purple') {
// ★ 修复:使用 ?? 而非 ||,避免 packmoney=0 时被误判为 falsy 而回退到默认值2
colorFee = STATE.packmoney ?? 2;
}
let total = afterRate + colorFee;
let discount = parseFloat(document.getElementById('discount').value) || 0;
const limit = STATE.discount_limit;
if (discount > limit) { discount = limit; document.getElementById('discount').value = limit; }
if (discount < 0) discount = 0;
const discAmt = Math.round(total * discount / 100);
const final = total - discAmt;
document.getElementById('summary').innerHTML = `
<div class="summary-line"><span>商品小计</span><span>${subtotal} 债券</span></div>
<div class="summary-line"><span>${settlement==='shulker'?'潜影盒费率':'背包费率'} (${rate*100}%)</span><span>${colorFee?('+'+colorFee):''}</span></div>
${ecoPct>0?`<div class="summary-line"><span class="disc">环保单 ${ecoPct}折</span><span class="disc">-${ecoAmt} 债券</span></div>`:''}
<div class="summary-line"><span>应收原价</span><span>${total} 债券</span></div>
<div class="summary-line"><span class="disc">手动折扣 ${discount}%</span><span class="disc">-${discAmt} 债券</span></div>
${payMode === 'cash' ? '<div class="summary-line" style="border-color:var(--orange);color:var(--orange)"><span>💵 现金收款模式</span><span>仅记账,不扣债券</span></div>' : ''}
<div class="summary-line total"><span>实收${payMode==='cash'?'(记账)':''}</span><span class="v">${final} ${payMode==='cash'?'债券':''}</span></div>`;
payBtn.disabled = false;
}
// ===== 玩家查询 =====
async function checkPlayer() {
const name = document.getElementById('playerName').value.trim();
if (!name) { toast('请输入玩家名', 'err'); return; }
try {
const d = await api('api/cashier.php?action=player_check&player=' + encodeURIComponent(name));
if (!d.success) { toast(d.message, 'err'); return; }
currentPlayer = name;
playerBalance = d.data.balance;
document.getElementById('balVal').textContent = playerBalance;
const dot = document.getElementById('onlineDot');
const txt = document.getElementById('onlineTxt');
if (d.data.online) { dot.className = 'online-dot on'; txt.textContent = '在线'; }
else { dot.className = 'online-dot off'; txt.textContent = '离线'; }
recalc();
} catch(e) { toast('查询失败: ' + e.message, 'err'); }
}
// ===== 确认收款 =====
async function confirmPay() {
const ids = Object.keys(cart);
if (!ids.length) { toast('购物车为空', 'err'); return; }
if (!currentPlayer) { toast('请先查询目标玩家', 'err'); return; }
const discount = parseFloat(document.getElementById('discount').value) || 0;
if (discount > STATE.discount_limit) { toast('折扣超过权限上限('+STATE.discount_limit+'%)', 'err'); return; }
const items = ids.map(id => ({ item_id: id, amount: cart[id] }));
const body = new URLSearchParams();
body.set('items', JSON.stringify(items));
body.set('settlement', settlement);
body.set('pay_mode', payMode); // ★ 新增:收款模式(bond/cash)
body.set('shulker_color', document.getElementById('shulkerColor').value);
body.set('player', currentPlayer);
body.set('discount_percent', String(discount));
if (STATE.role === 'admin') body.set('password', document.getElementById('adminPw').value || '');
const btn = document.getElementById('payBtn');
btn.disabled = true;
try {
const d = await api('api/shop.php?action=buy_cart', { method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: body.toString() });
if (d.need_password) {
// 管理员大额需密码:弹窗输入后重试
promptAdminPassword().then(pw => {
if (pw === null) { btn.disabled = false; return; }
body.set('password', pw);
api('api/shop.php?action=buy_cart', { method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: body.toString() })
.then(d2 => finishPay(d2)).catch(e => { toast(e.message,'err'); btn.disabled=false; });
});
return;
}
finishPay(d);
} catch(e) { toast('收款失败: ' + e.message, 'err'); btn.disabled = false; }
}
function finishPay(d) {
const btn = document.getElementById('payBtn');
if (d.success) {
// ★ 弹出打包小票(打印收据)
showReceipt(d.data);
cart = {}; renderCart();
document.getElementById('summary').innerHTML = '';
document.getElementById('discount').value = 0;
btn.disabled = true;
checkPlayer(); loadOrders();
} else {
toast(d.message || '收款失败', 'err');
btn.disabled = false;
}
}
// ===== 打包小票(收据)=====
function showReceipt(data) {
if (!data) return;
const items = (data.items && Array.isArray(data.items)) ? data.items : [];
const now = new Date();
const pad = n => String(n).padStart(2,'0');
const ts = now.getFullYear()+'-'+pad(now.getMonth()+1)+'-'+pad(now.getDate())+' '+pad(now.getHours())+':'+pad(now.getMinutes())+':'+pad(now.getSeconds());
const modeName = data.mode_name || (data.settlement==='shulker'?'潜影盒打包':'塞背包');
const payName = (data.payment_method || payMode) === 'cash' ? '现金(记账)' : '债券扣款';
const operator = STATE.role === 'admin' ? '管理员' : (STATE.username || '收银员');
// 明细行
let itemHtml = items.map(it => `
<div class="r-line r-item"><span class="ri-name">${esc(it.name)} x${it.amount}</span><span>${it.line_total} 债券</span></div>`).join('');
// 折扣优惠(背包费率+环保单减免的合计,original_price 已含费率与环保减免)
let promoLine = '';
const promoSaved = (parseInt(data.subtotal) || 0) - (parseInt(data.original_price) || 0);
if (promoSaved > 0) {
promoLine = `<div class="r-line"><span>${data.settlement==='backpack'?'环保/费率优惠':'费率优惠'}</span><span>-${promoSaved} 债券</span></div>`;
}
// 手动折扣
let discLine = '';
if (parseInt(data.discount_percent)>0) {
discLine = `<div class="r-line"><span>手动折扣 ${data.discount_percent}%</span><span>-${data.discount_amount} 债券</span></div>`;
}
const body = document.getElementById('receiptBody');
body.innerHTML = `
<h2>🧾 SDF1 商城</h2>
<div class="r-sub">打包小票 / PACKING RECEIPT</div>
<div class="r-line"><span>订单号</span><span>${esc(data.order_no || '—')}</span></div>
<div class="r-line"><span>时间</span><span>${ts}</span></div>
<div class="r-line"><span>玩家</span><span>${esc(data.player)}</span></div>
<div class="r-line"><span>操作员</span><span>${esc(operator)}</span></div>
<div class="r-line"><span>结算方式</span><span>${esc(modeName)}</span></div>
<div class="r-sep"></div>
${itemHtml}
<div class="r-sep"></div>
<div class="r-line"><span>商品小计</span><span>${data.subtotal} 债券</span></div>
${promoLine}
${discLine}
<div class="r-line"><span>收款方式</span><span>${payName}</span></div>
<div class="r-total r-line"><span>实收合计</span><span>${data.total_price} 债券</span></div>
<div class="r-foot">感谢惠顾 · 请妥善保管小票</div>
<div class="r-actions">
<button class="r-print" onclick="printReceipt()">🖨 打印小票</button>
<button class="r-done" onclick="closeReceipt()">完成</button>
</div>`;
document.getElementById('receiptOverlay').classList.add('show');
}
function printReceipt() { window.print(); }
function closeReceipt() { document.getElementById('receiptOverlay').classList.remove('show'); }
// 点击遮罩空白处关闭
document.getElementById('receiptOverlay').addEventListener('click', e => { if (e.target.id === 'receiptOverlay') closeReceipt(); });
function promptAdminPassword() {
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'glass-alert-overlay show';
overlay.innerHTML = `<div class="glass-alert-card">
<div class="alert-icon">🔐</div>
<div class="alert-msg">金额 ≥ 1000 债券,需确认管理员密码</div>
<input class="alert-input" type="password" id="apw" placeholder="管理员密码" autofocus>
<div class="alert-btns">
<button class="ag-cancel" onclick="this.closest('.glass-alert-overlay').remove();window.__apwResolve(null)">取消</button>
<button class="ag-ok" onclick="const v=document.getElementById('apw').value;this.closest('.glass-alert-overlay').remove();window.__apwResolve(v)">确认</button>
</div>
</div>`;
document.body.appendChild(overlay);
window.__apwResolve = resolve;
document.getElementById('apw').addEventListener('keydown', e => { if (e.key==='Enter'){ const v=e.target.value; overlay.remove(); resolve(v);} });
setTimeout(()=>document.getElementById('apw').focus(), 50);
});
}
// ===== 订单记录 =====
async function loadOrders() {
try {
const d = await api('api/cashier.php?action=order_list&limit=5');
const el = document.getElementById('orderList');
if (!d.success || !d.data || !d.data.length) { el.innerHTML = '<div style="color:var(--dim);font-size:12px;padding:10px">暂无订单</div>'; return; }
el.innerHTML = d.data.map(o => {
const items = (o.items_detail && Array.isArray(o.items_detail)) ? o.items_detail.map(i=>esc(i.name)+'x'+i.amount).join('、') : '';
const disc = (parseInt(o.discount_percent)>0) ? ` <span class="disc">(${o.discount_percent}% off -${o.discount_amount})</span>` : '';
return `<div class="order-row">
<div class="ot"><span class="ono">${esc(o.order_no)}</span><span>${o.operator_type==='admin'?'管理员':'收银员'}:${esc(o.operator_name)}</span></div>
<div class="od">玩家 ${esc(o.player_name)} · ${items}${disc}</div>
<div class="od">实收 <b style="color:var(--green)">${o.total_price}</b> 债券 · ${new Date((o.created_at||0)*1000).toLocaleString('zh-CN')}</div>
</div>`;
}).join('');
} catch(e) { /* 忽略 */ }
}
// ===== 启动 =====
(function(){
const saved = localStorage.getItem('sdf1_cashier_theme');
if (saved) { document.documentElement.style.setProperty('--bg', saved); document.body.style.background = saved; }
if (STATE.logged_in) initApp();
})();
</script>
</body>
</html>
-125
View File
@@ -1,125 +0,0 @@
<?php
/**
* Web通信系统 - 配置文件
*
* ★ 本文件随仓库分发,里面的值全部是占位符 —— 请改成你自己的。
* 任何真实口令 / 密钥 / IP / 令牌都严禁写进仓库(2026-09-29 用户指令)。
* 部署脚本也不会上传本文件,线上 config.php 只在服务器上由 admin_2fa_setup.php 改写。
*
* 拿到代码第一次要改的东西:
* 1. SECRET_KEY —— 插件(Java)与本 Web 端必须一字不差地相同,否则所有同步接口 401
* 2. ADMIN_PASS —— 后台管理密码
* 3. SMTP_* —— 邮件发信用途(不发信可以不管)
* 4. GAME_*_DB / DIR —— 你服务器上游戏数据库的实际路径
* 最下面 SEC-UPDATE 区故意是空的:后台访问令牌、IP 白名单、二次验证这三项
* 在你自己的服务器上首次生成 / 由你自己填,不会进仓库。
*/
// ===== 安全密钥(插件和Web端必须一致) =====
define('SECRET_KEY', 'REPLACE_ME_SECRET_KEY');
// ===== Token配置 =====
define('TOKEN_EXPIRE_SECONDS', 86400); // 24小时
// ===== 数据库路径 =====
define('DB_PATH', __DIR__ . '/db/web.db');
// ★ 收银台订单独立库:与 web.db(Java 高频写入)彻底解耦,消除 SQLite 文件锁竞争
// 订单记录纯属 PHP 收银台自己的数据,Java 永不读写此库
define('ORDERS_DB_PATH', __DIR__ . '/db/orders.db');
// ===== 收银台订单号(2026-10-06 多服隔离)=====
// ★ 平台 MySQL 的 pay_order 是多商户/多服务器共用表。以前创单和补单脚本都把 'RE'
// 写死在代码里,结果生产服和测试服用同一个前缀 —— 测试服的补单器每 20 秒轮询一次,
// 就把生产服的已支付单也捡走补进了测试库(互相串数据)。
// ★ 现在创单(pay.php)与三个补单脚本(poller_online / pay_poller / recharge_orders_api)
// 统一读下面这个常量:前缀一致才互认,各部署各改各的即可隔离。
// ★ 注意:改前缀只影响「新订单」与「补单器还认哪些单」,历史订单数据不受影响;
// 若某次改了前缀,改动前已生成、尚未补单的订单将不再被本端补单器匹配(按需保留)。
define('PAY_ORDER_PREFIX', 'RE'); // 生产服 'RE',测试服请改成例如 'RT'
// ★ 订单号格式(pay.php createOrder):
// 前缀 + 年月日时分秒 + 4位随机校验码 + 玩家名
// 例:RE20261006171418Q101youpaishidifu
// 加玩家名是为了在共用的 pay_order 表里一眼看出这单属于谁,排查串服问题时最快。
// 只允许字母/数字/下划线(Minecraft 用户名规则),其余字符会被剔除。
// ★ PAY_ORDER_MAX_LEN:平台 out_trade_no 字段长度护栏(彩虹易支付标准为 varchar(64))。
// 超长时自动裁短玩家名段,绝不截断前缀/时间戳/校验码 —— 前缀被截会导致补单器再也认不出这单。
define('PAY_ORDER_MAX_LEN', 64);
// ★ 债券换算不在这里配:金额→债券完全由后台「面板-充值商店配置」(shop_configs.bond_reward)
// 决定,代码不干预比例;仅在本地订单记录缺失时才用 poller_online.php 里的兜底估算公式。
// 运维想改成 1 元 10 券还是 1 元 100 券,直接在后台充值商店里调即可。
// ===== 管理员认证 =====
define('ADMIN_USER', 'admin');
define('ADMIN_PASS', 'REPLACE_ME_ADMIN_PASSWORD'); // 管理员密码
// ===== 游戏数据库路径(用于同步) =====
define('GAME_BOND_DB', 'REPLACE_ME_GAME_BOND_DB_PATH');
define('GAME_LOGIN_DB', 'REPLACE_ME_GAME_LOGIN_DB_PATH');
define('GAME_SHOP_DIR', 'REPLACE_ME_GAME_SHOP_DIR_PATH');
// ===== Web子目录路径(如 /plugin 或 /test1,根目录则留空) =====
define('WEBSUB_DIR', '/plugin');
// ★ 2026-10-07:已删除「Java插件回调端口 CALLBACK_PORT」与「Java游戏服务器地址
// GAME_SERVER_HOST」两段 —— PHP→Java 从来只走 web.db 的 SQL 队列(四条共享队列表,
// Java 5 秒轮询消费),没有 PHP→Java 的 HTTP 调用,这两项是历史死配置(全库无引用)。
// ===== 辅助函数:构建相对路径 =====
function webPath($path = '') {
return trim(WEBSUB_DIR, '/') . ($path ? '/' . trim($path, '/') : '');
}
// ===== SMTP邮件配置 =====
define('SMTP_HOST', 'REPLACE_ME_SMTP_HOST');
define('SMTP_PORT', '465');
define('SMTP_USER', 'REPLACE_ME_SMTP_USER');
define('SMTP_PASS', 'REPLACE_ME_SMTP_PASSWORD');
define('SMTP_SENDER_NAME', 'Sdf1_login');
define('SMTP_USE_SSL', true);
// ===== 人机验证码:全项目只用一家,二选一(改下面这一行的值即可)=====
// 'CF' → Cloudflare Turnstile(默认:无感、体验最好、基本不用点)
// 'VA' → VAPTCHA V4(备用:需要点一下"发起验证")
// 'off'→ 关闭人机验证
// 取值不区分大小写、兼容全名:cf / cloudflare / turnstile 都算 CF;va / vaptcha 都算 VA。
//
// ★ 三个场景(管理员登录、用户后台账密登录、正版验证提交前)全部跟着这一行走,
// 不会出现"这里是 CF、那里是 VA"的混用 —— 全站厂商由本常量单独决定。
// ★ 密钥不在本文件:CF 的 sitekey/secret 与 VA 的 VID/VKey 分别写在 captcha_keys.php
// 的 Cloudflare / VAPTCHA 两段里,只填你启用的那家即可(仓库里是空模板,真值只放服务器)。
// ★ 本文件没定义本常量时(老版本线上配置),一律按默认 'CF' 处理。
define('CAPTCHA_PROVIDER', 'CF'); // ← 只改这个值:'CF' 用 Cloudflare,'VA' 用 VAPTCHA
// ===== 紧急安全加固(2026-09-29 入侵事件响应,实现见 security.php)=====
// 第一层:TOTP 二次验证 —— 1Password / Microsoft Authenticator 等标准 6 位动态码(仅校验,无推送)。
// 第二层:管理面 IP 白名单 —— 未配置白名单时,仅服务器本机 127.0.0.1 与持有 boot 引导令牌的
// 人可访问后台;其余任何请求一律返回 nginx 原生 404 页(最小化信息透露:让探测者
// 分不清是文件不存在还是被拦截),拒绝记录写入 db/security.log。
// ★ 例外 = 全新部署的第一次访问:本区还是空的、web.db 里也没有引导标记时先放行,
// 引导你把自己的 IP 白名单 / 访问令牌 / 2FA 配齐;配置页一保存即自动转强制模式。
// 第三层:admin.php 动态访问令牌 —— 只有 https://域名/plugin/admin.php?token=<SEC_ACCESS_TOKEN>
// 才能打开后台;不带令牌(或令牌错)的请求一律返回 nginx 原生 404 页,
// 与"服务器上根本没有这个文件"表现完全一致,探测者无从判断。
//
// 下面这个区段故意不写任何 define —— 这些是每个部署者自己的安全隐私,严禁入库:
// SEC_BOOT_TOKEN 引导令牌(被锁在外面时的逃生舱)
// SEC_ACCESS_TOKEN 后台访问令牌(第三层门禁)
// SEC_EPOCH 安全纪元(部署加固时作废此前的全部活会话)
// ADMIN_IP_WHITELIST 你自己的 IP 白名单
// SEC_2FA_ENABLED / SEC_2FA_SECRET 你自己的二次验证开关与密钥
// 首次访问 https://域名/plugin/admin_2fa_setup.php 并保存任意一项时,缺失的键会由该页
// 在你自己的服务器上随机生成并就地补全(写前自动备份到 db/config_bak/),
// 同时在 web.db 的 sec_bootstrap 表写入一行"引导标记"——
// 判据 = 本区已有令牌 或 db 有引导标记(任一成立即强制模式)。
// 被锁在外面时的恢复方法:宝塔/SSH 查看本文件补全后的 SEC_BOOT_TOKEN,访问
// https://你的域名/plugin/admin_2fa_setup.php?boot=<令牌>
// 进入配置页把自己当前 IP 加入白名单(boot 令牌本身也是配置页入口)。
// ★ 令牌全丢的最后手段(2026-10-09):宝塔/SSH 手工给下面 SEC-UPDATE 区的
// ADMIN_IP_WHITELIST 数组加一条字面量 '0.0.0.0' —— 应急模式立即生效:
// 除服务器本机外全站所有页面/API 一律 302 锁回 admin_2fa_setup.php(免密进入),
// 按该页红色横幅指引删掉 0.0.0.0 + 「重新生成令牌」即恢复常态。
// ⚠️ 0.0.0.0 只是应急门票(等价全网可进引导页),用完必须立刻删除。
// >>>SEC-UPDATE-BEGIN 本区段由 admin_2fa_setup.php 自动维护,请勿手工改动标记行
// >>>SEC-UPDATE-END
-3854
View File
File diff suppressed because it is too large Load Diff
-398
View File
@@ -1,398 +0,0 @@
<?php
// ===== 数据安全护栏 / 灾后自愈(2026-10-10)=====
// 用户原则(原话):「自愈只建立在安全的情况下,其他情况以数据安全作为第一优先级」。
// · 数据库能连上但被错误清空(TRUNCATE/删表)→ recover:补表结构、把仍为空的表
// 从最近一次可读备份补推 SQL 数据,然后放行与 Java 的握手,等 Java 把其余数据补推回来;
// · 数据库能连上但「数据不对」(sql.md 指错库/指错机)→ block:停止对该库的一切操作、
// 拒绝与 Java 握手(含对账),提醒登录的管理员并引导修改 sql.md 数据库配置段;
// · 块模式绝不软回退 SQLite(那是把数据写进另一个库,比停机更危险)。
// 状态文件:data/.db_guard.json(盘上,不依赖数据库——库没了他还在)。
// 节流:每侧至多 240 秒做一次完整体检,避免每次请求都 COUNT 全表。
if (!defined('DF_GUARD_TTL')) define('DF_GUARD_TTL', 240); // 体检节流(秒)
if (!defined('DF_GUARD_BACKUP_MAX_AGE')) define('DF_GUARD_BACKUP_MAX_AGE', 14 * 86400); // 备份可补推的最大年龄
if (!defined('DF_GUARD_MAX_STMTS')) define('DF_GUARD_MAX_STMTS', 3000); // 单次补推最多执行的语句数
/** 块模式异常:穿透 dbOpen 的 SQLite 软回退 catch */
class dfGuardBlockException extends \Exception
{
}
/** 状态文件路径(data/ 不存在则建) */
function dfGuardStateFile()
{
$dir = __DIR__ . '/data';
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
return $dir . '/.db_guard.json';
}
function dfGuardReadState()
{
$f = dfGuardStateFile();
if (!is_file($f)) return array();
$j = @json_decode((string)@file_get_contents($f), true);
return is_array($j) ? $j : array();
}
function dfGuardWriteState($st)
{
$f = dfGuardStateFile();
$tmp = $f . '.' . getmypid() . '.tmp';
@file_put_contents($tmp, json_encode($st, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
if (!is_file($tmp)) return false;
return @rename($tmp, $f);
}
/** 每侧的核心表:这些表集体归零 = 库被清空的最可靠信号 */
function dfGuardCoreTables($side)
{
if ($side === 'java') {
return array('users', 'game_transactions', 'weblogin_credentials', 'web_player_bans');
}
return array('web_item_sn', 'web_sn_stock', 'web_sn_log');
}
/** 当前处于块状态的侧(供 sync.php 拒绝握手 / 登录页横幅读取,不打库) */
function dfGuardBlockedSides()
{
$st = dfGuardReadState();
$out = array();
foreach ($st as $side => $s) {
if (is_array($s) && isset($s['mode']) && $s['mode'] === 'block' && !empty($s['alert'])) {
$out[] = $side . '库:' . $s['alert'];
}
}
return $out;
}
/** 某侧的块原因(dbOpen 抛异常用) */
function dfGuardBlockReason($side)
{
$st = dfGuardReadState();
if (isset($st[$side]['alert']) && $st[$side]['alert']) return (string)$st[$side]['alert'];
return '数据库状态异常(身份与上次运行不一致),已停止操作并拒绝握手,请检查 sql.md 数据库配置段。';
}
/**
* 纯判定函数(可单测):给定上次体检快照与本次观测,返回 mode。
* @param array|null $prev 上次快照:db/host/port/tables/rows/core/ts
* @param array $now 本次观测:db/host/port/tables(array)/rows(int)/core(array 表=>行数)
* @return array array('mode' => 'ok'|'recover'|'block', 'why' => string)
*/
function dfGuardDecide($prev, $now)
{
$db = isset($now['db']) ? (string)$now['db'] : '';
$host = isset($now['host']) ? (string)$now['host'] : '';
$port = isset($now['port']) ? (int)$now['port'] : 0;
// 没上次快照 → 首次见,先记账不报警(部署/状态文件被删后的正常冷启动)
if (!is_array($prev) || empty($prev)) {
return array('mode' => 'ok', 'why' => 'bootstrap');
}
$pdb = isset($prev['db']) ? (string)$prev['db'] : '';
$phost = isset($prev['host']) ? (string)$prev['host'] : '';
$pport = isset($prev['port']) ? (int)$prev['port'] : 0;
// 身份变化(库名/主机/端口)→ 指错了地方:块,绝不往里写
if ($pdb !== $db || $phost !== $host || $pport !== $port) {
return array('mode' => 'block', 'why' => 'identity_mismatch');
}
$core = dfGuardCoreTables($pdb === 'caoyuanphp' || strpos($pdb, 'php') !== false ? 'web' : 'java');
// 用 prev 记忆的侧别更稳;这里退化用库名猜,调用方会以 $side 覆盖 core 集合
$tables = isset($now['tables']) ? $now['tables'] : array();
$coreNow = isset($now['core']) && is_array($now['core']) ? $now['core'] : array();
$rows = isset($now['rows']) ? (int)$now['rows'] : -1;
$prevRows = isset($prev['rows']) ? (int)$prev['rows'] : 0;
// 核心表一张不剩 → 库被连根清空/删库:走 recover(重建+补推+等 Java)
$haveCore = 0;
foreach ($core as $t) { if (in_array($t, $tables, true)) $haveCore++; }
if ($haveCore === 0 && !empty($prev['tables'])) {
return array('mode' => 'recover', 'why' => 'tables_wiped');
}
// 表都在但整体行数塌了(<=2% 或绝对空)→ 被清空:recover
if ($rows >= 0 && $prevRows > 0) {
$floor = max(50, (int)($prevRows * 0.02));
if ($rows <= $floor) {
return array('mode' => 'recover', 'why' => 'rows_wiped');
}
// 核心表精确计数佐证(TABLE_ROWS 是估计值,避免误判)
$zeroCore = 0;
$checkedCore = 0;
foreach ($coreNow as $t => $c) {
$checkedCore++;
if ((int)$c <= 0) $zeroCore++;
}
if ($checkedCore > 0 && $zeroCore === $checkedCore) {
return array('mode' => 'recover', 'why' => 'core_zeroed');
}
}
return array('mode' => 'ok', 'why' => 'healthy');
}
/**
* 连接后体检(在 initTables 之前调用,块模式直接抛,避免往可疑库里建表)。
* @return string 'ok'|'recover'|'block'
*/
function dfGuardCheck($mdb, $conn, $side)
{
$st = dfGuardReadState();
$prev = isset($st[$side]) && is_array($st[$side]) ? $st[$side] : null;
$now = time();
// 节流:刚体检过 → 直接沿用缓存判定(块/恢复态也一样缓存,行为一致)
if (is_array($prev) && isset($prev['ts']) && ($now - (int)$prev['ts']) < DF_GUARD_TTL
&& in_array(isset($prev['mode']) ? $prev['mode'] : '', array('ok', 'recover', 'block'), true)) {
return $prev['mode'];
}
$dbName = isset($conn['name']) ? (string)$conn['name'] : '';
$host = isset($conn['host']) ? (string)$conn['host'] : '';
$port = isset($conn['port']) ? (int)$conn['port'] : 3306;
// ---- 完整体检 ----
$tables = array();
$rs = $mdb->query("SELECT table_name FROM information_schema.tables WHERE table_schema='"
. $mdb->escapeString($dbName) . "'");
if ($rs) {
while ($row = $rs->fetchArray(SQLITE3_ASSOC)) $tables[] = (string)$row['table_name'];
}
$estRows = 0;
$rs = $mdb->query("SELECT SUM(TABLE_ROWS) s FROM information_schema.tables WHERE table_schema='"
. $mdb->escapeString($dbName) . "'");
if ($rs && ($row = $rs->fetchArray(SQLITE3_ASSOC))) $estRows = (int)$row['s'];
$core = dfGuardCoreTables($side);
$coreNow = array();
foreach ($core as $t) {
if (!in_array($t, $tables, true)) continue;
$v = dfGuardScalar($mdb, "SELECT COUNT(*) FROM `" . $t . "`");
if ($v !== null) $coreNow[$t] = (int)$v;
}
$nowInfo = array(
'db' => $dbName, 'host' => $host, 'port' => $port,
'tables' => $tables, 'rows' => $estRows, 'core' => $coreNow,
);
$decide = dfGuardDecide($prev, $nowInfo, $side);
$mode = $decide['mode'];
$entry = array(
'db' => $dbName, 'host' => $host, 'port' => $port,
'tables' => count($tables), 'rows' => $estRows,
'core' => $coreNow, 'ts' => $now, 'mode' => $mode,
);
if ($mode === 'block') {
$entry['alert'] = 'sql.md 里这一侧指向的数据库与上次运行不一致(上次 '
. (is_array($prev) ? ($prev['host'] . ':' . $prev['port'] . '/' . $prev['db']) : '?')
. ' → 现在 ' . $host . ':' . $port . '/' . $dbName . ')。'
. '为避免写错库破坏数据,已停止对该库的一切操作并拒绝与 Java 握手。'
. '请登录服务器核对 sql.md 的数据库配置段(地址/端口/库名/账号),改对后本护栏会自动恢复。';
} elseif ($mode === 'recover') {
$entry['alert'] = '检测到该库被清空(' . $decide['why'] . '),已执行自愈:补表结构并尝试从最近备份补推 SQL 数据,'
. '随后放行 Java 握手补推其余数据。PHP 侧自有表(工单/订单/流水等)只能从备份恢复,请检查 debug.log 的补推明细。';
} else {
$entry['alert'] = null;
}
$st[$side] = $entry;
dfGuardWriteState($st);
if ($mode === 'block') {
dfGuardLog('dbGuard[' . $side . '] ★ 块模式:' . $entry['alert']);
} elseif ($mode === 'recover') {
dfGuardLog('dbGuard[' . $side . '] ★ 恢复模式:' . $decide['why'] . ',行数 '
. $estRows . '(上次 ' . (is_array($prev) ? (int)$prev['rows'] : 0) . ')');
}
return $mode;
}
function dfGuardScalar($mdb, $sql)
{
try {
$rs = $mdb->query($sql);
if ($rs && ($row = $rs->fetchArray(SQLITE3_ASSOC))) {
foreach ($row as $v) return $v;
}
} catch (\Throwable $e) { return null; }
return null;
}
function dfGuardLog($msg)
{
if (function_exists('debugLog')) { debugLog($msg); return; }
@error_log($msg);
}
/** 恢复模式动作:补表结构(db_schema_mysql.sql)+ 把仍为空的表从最近可读备份补推数据 */
function dfGuardRecover($mdb, $side)
{
$done = array('schema' => 0, 'restored' => array(), 'skipped' => array(), 'err' => array());
// 1) 缺的表用内置 schema 补(initTables 通常已经补过,这里是双保险)
$schemaFile = __DIR__ . '/db_schema_mysql.sql';
if (is_file($schemaFile) && is_readable($schemaFile)) {
$done['schema'] = dfGuardApplySchema($mdb, $side, $schemaFile);
}
// 2) 仍为空的表 → 从最近一次可读备份补推(只补 0 行表,绝不覆盖有数据的表)
try {
$dump = dfGuardPickDump($side);
if ($dump === null) {
$done['skipped'][] = 'no_readable_dump';
} else {
$done['restored'] = dfGuardRestoreEmptyTables($mdb, $dump);
}
} catch (\Throwable $e) {
$done['err'][] = $e->getMessage();
}
dfGuardLog('dbGuard[' . $side . '] 自愈执行明细: ' . json_encode($done, JSON_UNESCAPED_UNICODE));
return $done;
}
/** 从 db_schema_mysql.sql 里把本侧缺失的表建出来(CREATE TABLE IF NOT EXISTS) */
function dfGuardApplySchema($mdb, $side, $schemaFile)
{
$javaTables = function_exists('dbJavaTables') ? dbJavaTables() : array();
$sql = (string)@file_get_contents($schemaFile);
if ($sql === '') return 0;
// 只认 CREATE TABLE 段:去掉 DROP/LOCK/UNLOCK(恢复期绝不能删表)
$lines = preg_split('/\r\n|\r|\n/', $sql);
$buf = '';
$statements = array();
foreach ($lines as $ln) {
$t = trim($ln);
if ($t === '' || $t[0] === '#' || strpos($t, '--') === 0) continue;
if (preg_match('/^(DROP|LOCK|UNLOCK)\b/i', $t)) continue;
$buf .= $ln . "\n";
if (substr(rtrim($t), -1) === ';') {
$statements[] = $buf;
$buf = '';
}
}
if (trim($buf) !== '') $statements[] = $buf;
$created = 0;
foreach ($statements as $s) {
if (!preg_match('/^CREATE TABLE\s+(?:IF NOT EXISTS\s+)?`?([A-Za-z0-9_]+)`?/i', ltrim($s), $m)) continue;
$tbl = $m[1];
// 归属过滤:java 组表只准建到 java 侧,其余建到 web 侧
if ($side === 'java') {
if (!in_array($tbl, $javaTables, true)) continue;
} else {
if (in_array($tbl, $javaTables, true)) continue;
}
// 表已存在就跳过
$v = dfGuardScalar($mdb, "SELECT COUNT(*) c FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='"
. $mdb->escapeString($tbl) . "'");
if ($v !== null && (int)$v > 0) continue;
try {
$stmt = preg_replace('/^CREATE TABLE\s+(?!IF NOT EXISTS)/i', 'CREATE TABLE IF NOT EXISTS ', ltrim($s), 1);
$mdb->exec($stmt);
$created++;
} catch (\Throwable $e) {
dfGuardLog('dbGuard: 建表失败 ' . $tbl . ': ' . $e->getMessage());
}
}
return $created;
}
/** 挑一份最近的可读备份(库名出现在文件名里、年龄合规、按 mtime 新优先) */
function dfGuardPickDump($side)
{
$st = dfGuardReadState();
$dbName = isset($st[$side]['db']) ? (string)$st[$side]['db'] : '';
if ($dbName === '') return null;
$cands = array_merge(
glob('/www/backup/dr*/*' . $dbName . '*.sql') ?: array(),
glob('/www/backup/*' . $dbName . '*.sql') ?: array(),
glob(__DIR__ . '/../backup/*' . $dbName . '*.sql') ?: array()
);
$best = null;
$bestMt = 0;
$now = time();
foreach ($cands as $f) {
if (!is_file($f) || !is_readable($f)) continue;
$mt = @filemtime($f);
if (!$mt || ($now - $mt) > DF_GUARD_BACKUP_MAX_AGE) continue;
if ($mt > $bestMt) { $bestMt = $mt; $best = $f; }
}
return $best;
}
/**
* 备份补推:只执行「目标表当前 0 行」的 INSERT / 缺失表的 CREATE,其余语句一律跳过。
* 返回 array('表名' => 补推行数)。
*/
function dfGuardRestoreEmptyTables($mdb, $dumpFile)
{
$out = array();
$fh = @fopen($dumpFile, 'r');
if (!$fh) return $out;
$buf = '';
$executed = 0;
while (!feof($fh)) {
$line = fgets($fh);
if ($line === false) break;
$buf .= $line;
if (rtrim($line) === '' || substr(rtrim($line), -1) !== ';') continue;
$stmt = trim($buf);
$buf = '';
if ($stmt === '') continue;
if ($executed >= DF_GUARD_MAX_STMTS) break;
if (preg_match('/^INSERT INTO\s+`?([A-Za-z0-9_]+)`?/i', $stmt, $m)) {
$tbl = $m[1];
$v = dfGuardScalar($mdb, "SELECT COUNT(*) c FROM `" . $tbl . "`");
if ($v === null) continue; // 表不存在:跳过(不该由备份建表)
if ((int)$v > 0) continue; // 有数据的表绝不覆盖
try {
$mdb->exec($stmt);
$n = 1;
$out[$tbl] = isset($out[$tbl]) ? $out[$tbl] + $n : $n;
$executed++;
} catch (\Throwable $e) {
dfGuardLog('dbGuard: 补推 ' . $tbl . ' 失败: ' . $e->getMessage());
}
} elseif (preg_match('/^CREATE TABLE\s+(?:IF NOT EXISTS\s+)?`?([A-Za-z0-9_]+)`?/i', $stmt, $m)) {
$tbl = $m[1];
$v = dfGuardScalar($mdb, "SELECT COUNT(*) c FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='"
. $mdb->escapeString($tbl) . "'");
if ($v !== null && (int)$v > 0) continue;
try {
$stmt2 = preg_replace('/^CREATE TABLE\s+(?!IF NOT EXISTS)/i', 'CREATE TABLE IF NOT EXISTS ', $stmt, 1);
$mdb->exec($stmt2);
$executed++;
} catch (\Throwable $e) {
dfGuardLog('dbGuard: 恢复期建表 ' . $tbl . ' 失败: ' . $e->getMessage());
}
}
}
fclose($fh);
return $out;
}
/**
* 管理登录页横幅:有未消除的护栏告警时就亮出来(块/恢复态都会显示)。
* 告警是派生状态:数据库恢复健康后下一次体检自动转 ok,横幅随之消失。
*/
function dfGuardAdminBanner()
{
$st = dfGuardReadState();
$msgs = array();
foreach ($st as $side => $s) {
if (is_array($s) && !empty($s['alert']) && in_array($s['mode'], array('block', 'recover'), true)) {
$label = ($side === 'java') ? 'Java 库(caoyuanjava)' : 'PHP 库(caoyuanphp)';
$msgs[] = '<b>[' . htmlspecialchars($label) . ']</b> ' . htmlspecialchars((string)$s['alert'])
. ' <span style="opacity:.8">(' . htmlspecialchars($s['mode'] === 'block' ? '已停止操作并拒绝握手' : '自愈进行中') . ')</span>';
}
}
if (!$msgs) return '';
$css = '<style>.df-guard-banner{margin:0 0 14px;padding:10px 12px;border:1px solid #f85149;'
. 'border-radius:8px;background:#3d1416;color:#ffb3b3;font-size:12px;line-height:1.6;text-align:left;}'
. '.df-guard-banner b{color:#ff8f8f;}</style>';
return $css . '<div class="df-guard-banner">⚠ 数据库安全护栏告警:<br>' . implode('<br>', $msgs) . '</div>';
}
-845
View File
@@ -1,845 +0,0 @@
-- SDF1 双库表结构快照(灾备自愈用,由生产 SHOW CREATE TABLE 生成)
-- 用法:db_guard.php 恢复模式据此补建缺失表(CREATE TABLE IF NOT EXISTS)
-- ================= aoyuanjava =================
-- aoyuanjava.ond_cache
CREATE TABLE IF NOT EXISTS `bond_cache` (
`player_name` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`amount` bigint(20) DEFAULT '0',
`updated_at` bigint(20) DEFAULT '0',
PRIMARY KEY (`player_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.dk
CREATE TABLE IF NOT EXISTS `cdk` (
`code` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`amount` bigint(20) DEFAULT '0',
`type` varchar(500) COLLATE utf8mb4_bin DEFAULT 'bond',
`used` bigint(20) DEFAULT '0',
`used_by` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`created_at` bigint(20) DEFAULT '0',
`used_at` bigint(20) DEFAULT '0',
PRIMARY KEY (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.dk_validate_requests
CREATE TABLE IF NOT EXISTS `cdk_validate_requests` (
`request_id` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`code` text COLLATE utf8mb4_bin NOT NULL,
`player_name` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`status` varchar(500) COLLATE utf8mb4_bin DEFAULT 'pending',
`created_at` bigint(20) NOT NULL,
PRIMARY KEY (`request_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.dk_validate_results
CREATE TABLE IF NOT EXISTS `cdk_validate_results` (
`request_id` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`code` text COLLATE utf8mb4_bin NOT NULL,
`status` text COLLATE utf8mb4_bin NOT NULL,
`amount` bigint(20) DEFAULT '0',
`message` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`used` bigint(20) DEFAULT '0',
`used_by` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`created_at` bigint(20) NOT NULL,
PRIMARY KEY (`request_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.onfirmed_transactions
CREATE TABLE IF NOT EXISTS `confirmed_transactions` (
`tx_id` varchar(325) COLLATE utf8mb4_bin NOT NULL,
`tx_type` varchar(325) COLLATE utf8mb4_bin NOT NULL,
`confirmed_at` bigint(20) NOT NULL,
PRIMARY KEY (`tx_id`,`tx_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.mail_codes
CREATE TABLE IF NOT EXISTS `email_codes` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`player_name` text COLLATE utf8mb4_bin NOT NULL,
`email` text COLLATE utf8mb4_bin NOT NULL,
`code` text COLLATE utf8mb4_bin NOT NULL,
`expire_at` bigint(20) NOT NULL,
`used` bigint(20) DEFAULT '0',
`created_at` bigint(20) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.ame_transactions
CREATE TABLE IF NOT EXISTS `game_transactions` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`java_id` bigint(20) NOT NULL,
`player_name` text COLLATE utf8mb4_bin NOT NULL,
`type` text COLLATE utf8mb4_bin NOT NULL,
`amount` bigint(20) NOT NULL,
`target_player` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`operator` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`reason` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`balance_before` bigint(20) DEFAULT '0',
`balance_after` bigint(20) DEFAULT '0',
`tx_time` bigint(20) NOT NULL,
`synced_at` bigint(20) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `java_id` (`java_id`),
KEY `idx_gt_player` (`player_name`(191),`tx_time`),
KEY `idx_gt_type` (`type`(191))
) ENGINE=InnoDB AUTO_INCREMENT=61640 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.ourly_active_players
CREATE TABLE IF NOT EXISTS `hourly_active_players` (
`player_name` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`first_seen` bigint(20) NOT NULL,
`last_seen` bigint(20) NOT NULL,
`hour_key` text COLLATE utf8mb4_bin NOT NULL,
PRIMARY KEY (`player_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.nline_player_hb
CREATE TABLE IF NOT EXISTS `online_player_hb` (
`id` bigint(20) NOT NULL DEFAULT '1',
`last_seen` bigint(20) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.nline_players
CREATE TABLE IF NOT EXISTS `online_players` (
`player_name` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`login_time` bigint(20) DEFAULT '0',
PRIMARY KEY (`player_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.assword_reset_requests
CREATE TABLE IF NOT EXISTS `password_reset_requests` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`player_name` text COLLATE utf8mb4_bin NOT NULL,
`requested_email` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`status` varchar(500) COLLATE utf8mb4_bin DEFAULT 'pending',
`admin_approved` bigint(20) DEFAULT '0',
`admin_email` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`created_at` bigint(20) NOT NULL,
`processed_at` bigint(20) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.assword_reset_tokens
CREATE TABLE IF NOT EXISTS `password_reset_tokens` (
`token` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`player_name` text COLLATE utf8mb4_bin NOT NULL,
`expire_at` bigint(20) NOT NULL,
PRIMARY KEY (`token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.layer_checkins
CREATE TABLE IF NOT EXISTS `player_checkins` (
`player_name` varchar(325) COLLATE utf8mb4_bin NOT NULL,
`checkin_date` varchar(325) COLLATE utf8mb4_bin NOT NULL,
`checkin_time` bigint(20) DEFAULT '0',
PRIMARY KEY (`player_name`,`checkin_date`),
KEY `idx_player_checkins_player` (`player_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.layer_daily_logins
CREATE TABLE IF NOT EXISTS `player_daily_logins` (
`player_name` varchar(325) COLLATE utf8mb4_bin NOT NULL,
`login_date` varchar(325) COLLATE utf8mb4_bin NOT NULL,
`first_login` bigint(20) DEFAULT '0',
`last_login` bigint(20) DEFAULT '0',
PRIMARY KEY (`player_name`,`login_date`),
KEY `idx_player_daily_logins_player` (`player_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.layer_ip_changes
CREATE TABLE IF NOT EXISTS `player_ip_changes` (
`player_name` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`old_ip` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`new_ip` text COLLATE utf8mb4_bin NOT NULL,
`changed_at` bigint(20) NOT NULL,
`synced_at` bigint(20) DEFAULT '0',
PRIMARY KEY (`player_name`),
KEY `idx_player_ip_changes_player` (`player_name`),
KEY `idx_player_ip_changes_time` (`changed_at`),
KEY `idx_player_ip_changes_newip` (`new_ip`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.layer_ip_locations
CREATE TABLE IF NOT EXISTS `player_ip_locations` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`ip_address` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`location` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`updated_at` bigint(20) DEFAULT '0',
`player_name` varchar(500) COLLATE utf8mb4_bin DEFAULT 'global',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_player_ip_locations_975887f3` (`ip_address`(191),`player_name`(191)),
KEY `idx_player_ip_locations_ip` (`ip_address`),
KEY `idx_player_ip_locations_loc` (`location`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.hop_config
CREATE TABLE IF NOT EXISTS `shop_config` (
`cfg_key` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`cfg_value` text COLLATE utf8mb4_bin NOT NULL,
PRIMARY KEY (`cfg_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.hop_items
CREATE TABLE IF NOT EXISTS `shop_items` (
`id` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`category` text COLLATE utf8mb4_bin NOT NULL,
`display_name` text COLLATE utf8mb4_bin NOT NULL,
`material` text COLLATE utf8mb4_bin NOT NULL,
`buy_price` bigint(20) DEFAULT '0',
`sell_price` bigint(20) DEFAULT '-1',
`stock` bigint(20) DEFAULT '-1',
`hourly_sales` bigint(20) DEFAULT '0',
`total_sales` bigint(20) DEFAULT '0',
`last_sync` bigint(20) DEFAULT '0',
`admin_stock` bigint(20) DEFAULT NULL,
`admin_buy_price` bigint(20) DEFAULT NULL,
`admin_sell_price` bigint(20) DEFAULT NULL,
`last_modified` bigint(20) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.ync_requests
CREATE TABLE IF NOT EXISTS `sync_requests` (
`player_name` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`created_at` bigint(20) NOT NULL,
PRIMARY KEY (`player_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.okens
CREATE TABLE IF NOT EXISTS `tokens` (
`token` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`player_name` text COLLATE utf8mb4_bin NOT NULL,
`purpose` varchar(500) COLLATE utf8mb4_bin DEFAULT 'general',
`created_at` bigint(20) NOT NULL,
`expires_at` bigint(20) NOT NULL,
`used` bigint(20) DEFAULT '0',
`used_at` bigint(20) DEFAULT '0',
PRIMARY KEY (`token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.sers
CREATE TABLE IF NOT EXISTS `users` (
`player_name` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`register_time` bigint(20) DEFAULT '0',
`last_login_time` bigint(20) DEFAULT '0',
`email` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`points` bigint(20) DEFAULT '0',
`gift_stage` bigint(20) DEFAULT '0',
`total_online_time` bigint(20) DEFAULT '0',
`last_login_location` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`last_login_ip` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`frozen` bigint(20) DEFAULT '0',
`freeze_token` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
PRIMARY KEY (`player_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eb_admin_changes
CREATE TABLE IF NOT EXISTS `web_admin_changes` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`change_type` text COLLATE utf8mb4_bin NOT NULL,
`target_id` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`target_name` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`change_data` varchar(500) COLLATE utf8mb4_bin DEFAULT '{}',
`created_at` bigint(20) DEFAULT '0',
`acknowledged` bigint(20) DEFAULT '0',
`acked_at` bigint(20) DEFAULT '0',
`status` varchar(500) COLLATE utf8mb4_bin DEFAULT 'pending',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eb_area_config
CREATE TABLE IF NOT EXISTS `web_area_config` (
`key` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`value` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
PRIMARY KEY (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eb_area_lands
CREATE TABLE IF NOT EXISTS `web_area_lands` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`owner` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`world` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`x1` bigint(20) DEFAULT '0',
`z1` bigint(20) DEFAULT '0',
`x2` bigint(20) DEFAULT '0',
`z2` bigint(20) DEFAULT '0',
`y1` bigint(20) DEFAULT '0',
`y2` bigint(20) DEFAULT '0',
`area_size` bigint(20) DEFAULT '0',
`created_at` bigint(20) DEFAULT '0',
`admin_changed` bigint(20) DEFAULT '0',
`deny_pvp` bigint(20) DEFAULT '0',
`deny_fall_damage` bigint(20) DEFAULT '0',
`deny_hunger` bigint(20) DEFAULT '0',
`deny_all_damage` bigint(20) DEFAULT '0',
`deny_fire_spread` bigint(20) DEFAULT '0',
`deny_explosion` bigint(20) DEFAULT '0',
`deny_mob_grief` bigint(20) DEFAULT '0',
`deny_block_place` bigint(20) DEFAULT '0',
`deny_block_break` bigint(20) DEFAULT '0',
`y_min` bigint(20) DEFAULT '0',
`y_max` bigint(20) DEFAULT '255',
`synced_at` bigint(20) DEFAULT '0',
`clear_effects` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`give_effects` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`clear_all_bad_effects` bigint(20) DEFAULT '0',
`deny_all_effects` bigint(20) DEFAULT '0',
`confiscate_items` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`deny_use_items` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`punish_commands` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`deny_drop` bigint(20) DEFAULT '0',
`deny_mount` bigint(20) DEFAULT '0',
`deny_ender_pearl` bigint(20) DEFAULT '0',
`deny_bow` bigint(20) DEFAULT '0',
`deny_potion` bigint(20) DEFAULT '0',
`deny_raid` bigint(20) DEFAULT '0',
`deny_item_frame` bigint(20) DEFAULT '0',
`deny_move` bigint(20) DEFAULT '0',
`deny_pickup` bigint(20) DEFAULT '0',
`deny_fire` bigint(20) DEFAULT '0',
`deny_thrown_projectiles` bigint(20) DEFAULT '0',
`deny_glowing` bigint(20) DEFAULT '0',
`deny_redstone_interaction` bigint(20) DEFAULT '0',
`deny_door_interaction` bigint(20) DEFAULT '0',
`deny_noteblock_jukebox` bigint(20) DEFAULT '0',
`deny_lead` bigint(20) DEFAULT '0',
`deny_crop_harvest` bigint(20) DEFAULT '0',
`deny_wool_shear` bigint(20) DEFAULT '0',
`deny_animal_feeding` bigint(20) DEFAULT '0',
`deny_mob_attack` bigint(20) DEFAULT '0',
`deny_container` bigint(20) DEFAULT '0',
`peace_mode` bigint(20) DEFAULT '0',
`peace_mode_duration` bigint(20) DEFAULT '0',
`peace_whitelist` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`enforce_game_mode` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`mode_exempt` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`enter_msg` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`leave_msg` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`confiscate_msg` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`warp_x` double DEFAULT '0',
`warp_y` double DEFAULT '0',
`warp_z` double DEFAULT '0',
`warp_yaw` double DEFAULT '0',
`warp_pitch` double DEFAULT '0',
`warp_world` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`enable_announce` bigint(20) DEFAULT '0',
`announce_template` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`txt_content` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`deny_fluid` bigint(20) DEFAULT '0',
`clear_all_bad` bigint(20) DEFAULT '0',
`is_public_building` bigint(20) DEFAULT '0',
`deny_entity_interact` bigint(20) DEFAULT '0',
`allow_visitor_teleport` bigint(20) DEFAULT '0',
`deny_sign_edit` bigint(20) DEFAULT '0',
`deny_spawn_egg` bigint(20) DEFAULT '0',
`deny_wax` bigint(20) DEFAULT '0',
`bad_effects` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=7072 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eb_area_permissions
CREATE TABLE IF NOT EXISTS `web_area_permissions` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`land_id` bigint(20) NOT NULL,
`land_name` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`player_name` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`role` varchar(500) COLLATE utf8mb4_bin NOT NULL DEFAULT 'visitor',
`permissions` varchar(1000) COLLATE utf8mb4_bin DEFAULT '',
`granted_at` bigint(20) DEFAULT '0',
`expires_at` bigint(20) DEFAULT '0',
`synced_at` bigint(20) DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `land_id` (`land_id`,`player_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eb_area_shop
CREATE TABLE IF NOT EXISTS `web_area_shop` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`land_id` bigint(20) NOT NULL,
`land_name` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`seller` text COLLATE utf8mb4_bin NOT NULL,
`permission` varchar(500) COLLATE utf8mb4_bin DEFAULT 'visitor',
`price` bigint(20) DEFAULT '0',
`duration` bigint(20) DEFAULT '86400',
`status` varchar(500) COLLATE utf8mb4_bin DEFAULT 'active',
`buyer` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`bought_at` bigint(20) DEFAULT '0',
`created_at` bigint(20) DEFAULT '0',
`synced_at` bigint(20) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eb_ip_blacklist
CREATE TABLE IF NOT EXISTS `web_ip_blacklist` (
`ip` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`reason` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`source` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`expire` bigint(20) DEFAULT '0',
`banned_at` bigint(20) DEFAULT '0',
PRIMARY KEY (`ip`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eb_login_confirmations
CREATE TABLE IF NOT EXISTS `web_login_confirmations` (
`player_name` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`confirmed_at` bigint(20) NOT NULL,
`consumed` bigint(20) DEFAULT '0',
PRIMARY KEY (`player_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eb_login_requests
CREATE TABLE IF NOT EXISTS `web_login_requests` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`player_name` text COLLATE utf8mb4_bin NOT NULL,
`password` text COLLATE utf8mb4_bin NOT NULL,
`request_time` bigint(20) NOT NULL,
`status` varchar(500) COLLATE utf8mb4_bin DEFAULT 'pending',
`result` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`result_time` bigint(20) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eb_login_verified
CREATE TABLE IF NOT EXISTS `web_login_verified` (
`player_name` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`verified_at` bigint(20) NOT NULL,
PRIMARY KEY (`player_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eb_player_bans
CREATE TABLE IF NOT EXISTS `web_player_bans` (
`target` varchar(325) COLLATE utf8mb4_bin NOT NULL,
`ban_type` varchar(325) COLLATE utf8mb4_bin NOT NULL DEFAULT 'name',
`reason` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`source` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`expire_time` bigint(20) DEFAULT '0',
`synced_at` bigint(20) DEFAULT '0',
PRIMARY KEY (`target`,`ban_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eb_plugin_admins
CREATE TABLE IF NOT EXISTS `web_plugin_admins` (
`player_name` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`synced_at` bigint(20) DEFAULT '0',
PRIMARY KEY (`player_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eb_register_requests
CREATE TABLE IF NOT EXISTS `web_register_requests` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`player_name` text COLLATE utf8mb4_bin NOT NULL,
`password_hash` text COLLATE utf8mb4_bin NOT NULL,
`salt` text COLLATE utf8mb4_bin NOT NULL,
`email` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`ip_address` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`created_at` bigint(20) NOT NULL,
`status` varchar(500) COLLATE utf8mb4_bin DEFAULT 'pending',
`processed_at` bigint(20) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eb_service_providers
CREATE TABLE IF NOT EXISTS `web_service_providers` (
`player_name` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`role` varchar(500) COLLATE utf8mb4_bin DEFAULT 'waiter',
`active` bigint(20) DEFAULT '1',
`join_time` bigint(20) DEFAULT '0',
PRIMARY KEY (`player_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eb_session_log
CREATE TABLE IF NOT EXISTS `web_session_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`player_name` text COLLATE utf8mb4_bin NOT NULL,
`ip_address` text COLLATE utf8mb4_bin NOT NULL,
`login_time` bigint(20) NOT NULL,
`session_token` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
PRIMARY KEY (`id`),
KEY `idx_web_session_log_player` (`player_name`(191)),
KEY `idx_web_session_log_time` (`login_time`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eb_transactions
CREATE TABLE IF NOT EXISTS `web_transactions` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`player_name` text COLLATE utf8mb4_bin NOT NULL,
`type` text COLLATE utf8mb4_bin NOT NULL,
`amount` bigint(20) NOT NULL,
`operator` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`reason` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`detail` varchar(1059) COLLATE utf8mb4_bin DEFAULT '',
`status` varchar(500) COLLATE utf8mb4_bin DEFAULT 'pending',
`created_at` bigint(20) DEFAULT '0',
`processed_at` bigint(20) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=341 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eb_user_group_members
CREATE TABLE IF NOT EXISTS `web_user_group_members` (
`player_name` varchar(325) COLLATE utf8mb4_bin NOT NULL,
`group_name` varchar(325) COLLATE utf8mb4_bin NOT NULL,
`added_by` varchar(500) COLLATE utf8mb4_bin DEFAULT 'system',
`added_time` bigint(20) DEFAULT '0',
`expiry_time` bigint(20) DEFAULT '0',
PRIMARY KEY (`player_name`,`group_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eb_user_groups
CREATE TABLE IF NOT EXISTS `web_user_groups` (
`group_name` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`display_name` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`display_color` varchar(500) COLLATE utf8mb4_bin DEFAULT '�f',
`display_emoji` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`priority` bigint(20) DEFAULT '0',
`land_price_per_sqm` bigint(20) DEFAULT '-1',
`max_lands` bigint(20) DEFAULT '-1',
`max_effects` bigint(20) DEFAULT '5',
`max_effect_level` bigint(20) DEFAULT '256',
`home_limit` bigint(20) DEFAULT '0',
`join_price` bigint(20) DEFAULT '0',
`auto_renew` bigint(20) DEFAULT '0',
`renew_price` bigint(20) DEFAULT '0',
`duration_minutes` bigint(20) DEFAULT '0',
`default_perms` varchar(500) COLLATE utf8mb4_bin DEFAULT '{}',
`synced_at` bigint(20) DEFAULT '0',
PRIMARY KEY (`group_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eblogin_credentials
CREATE TABLE IF NOT EXISTS `weblogin_credentials` (
`player_name` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`password_hash` text COLLATE utf8mb4_bin NOT NULL,
`salt` text COLLATE utf8mb4_bin NOT NULL,
`temp_password_hash` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`temp_pw_expire` bigint(20) DEFAULT '0',
`pushed_at` bigint(20) NOT NULL,
PRIMARY KEY (`player_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanjava.eblogin_tokens
CREATE TABLE IF NOT EXISTS `weblogin_tokens` (
`player_name` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`web_token` text COLLATE utf8mb4_bin NOT NULL,
`created_at` bigint(20) NOT NULL,
`expire_seconds` bigint(20) DEFAULT '600',
`registered` bigint(20) DEFAULT '1',
PRIMARY KEY (`player_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- ================= aoyuanphp =================
-- aoyuanphp.install_lock
CREATE TABLE IF NOT EXISTS `_install_lock` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`installed_at` varchar(32) NOT NULL,
`ip` varchar(45) NOT NULL DEFAULT '',
`mode` varchar(16) NOT NULL DEFAULT 'mysql',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
-- aoyuanphp.dmin_sessions
CREATE TABLE IF NOT EXISTS `admin_sessions` (
`session_id` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`created_at` bigint(20) NOT NULL,
`expires_at` bigint(20) NOT NULL,
PRIMARY KEY (`session_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.ashier_orders
CREATE TABLE IF NOT EXISTS `cashier_orders` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_no` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`operator_type` varchar(500) COLLATE utf8mb4_bin DEFAULT 'cashier',
`operator_name` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`player_name` text COLLATE utf8mb4_bin NOT NULL,
`items_detail` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`subtotal` bigint(20) DEFAULT '0',
`total_price` bigint(20) DEFAULT '0',
`discount_percent` bigint(20) DEFAULT '0',
`discount_amount` bigint(20) DEFAULT '0',
`settlement` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`payment_method` varchar(500) COLLATE utf8mb4_bin DEFAULT 'bond',
`status` varchar(500) COLLATE utf8mb4_bin DEFAULT 'completed',
`created_at` bigint(20) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.ashiers
CREATE TABLE IF NOT EXISTS `cashiers` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`password_hash` text COLLATE utf8mb4_bin NOT NULL,
`salt` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`discount_limit_percent` bigint(20) DEFAULT '0',
`created_at` bigint(20) DEFAULT '0',
`created_by` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`can_cash` bigint(20) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.nline_snapshots
CREATE TABLE IF NOT EXISTS `online_snapshots` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`snapshot_time` bigint(20) NOT NULL,
`online_count` bigint(20) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
KEY `idx_snap_time` (`snapshot_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.ay_orders
CREATE TABLE IF NOT EXISTS `pay_orders` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`out_trade_no` varchar(500) COLLATE utf8mb4_bin DEFAULT NULL,
`trade_no` text COLLATE utf8mb4_bin,
`player_name` text COLLATE utf8mb4_bin NOT NULL,
`tier_id` bigint(20) DEFAULT '0',
`money` text COLLATE utf8mb4_bin,
`bond_amount` bigint(20) DEFAULT '0',
`status` varchar(500) COLLATE utf8mb4_bin DEFAULT 'created',
`name` text COLLATE utf8mb4_bin,
`platform_sign` text COLLATE utf8mb4_bin,
`created_at` bigint(20) DEFAULT NULL,
`paid_at` bigint(20) DEFAULT NULL,
`submit_params` text COLLATE utf8mb4_bin,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.ending_player_validations
CREATE TABLE IF NOT EXISTS `pending_player_validations` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`player_name` text COLLATE utf8mb4_bin NOT NULL,
`request_type` varchar(500) COLLATE utf8mb4_bin DEFAULT 'general',
`request_data` varchar(500) COLLATE utf8mb4_bin DEFAULT '{}',
`status` varchar(500) COLLATE utf8mb4_bin DEFAULT 'pending',
`created_at` bigint(20) DEFAULT '0',
`validated_at` bigint(20) DEFAULT '0',
`result` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.eb_bond_records
CREATE TABLE IF NOT EXISTS `web_bond_records` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`player_name` text COLLATE utf8mb4_bin NOT NULL,
`amount` bigint(20) DEFAULT '0',
`record_type` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`recorded_by` varchar(500) COLLATE utf8mb4_bin DEFAULT 'system',
`description` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`created_at` bigint(20) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.eb_group_renew
CREATE TABLE IF NOT EXISTS `web_group_renew` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`player_name` text COLLATE utf8mb4_bin NOT NULL,
`group_name` text COLLATE utf8mb4_bin NOT NULL,
`renew_price` bigint(20) DEFAULT '0',
`duration_minutes` bigint(20) DEFAULT '0',
`req_id` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`status` varchar(500) COLLATE utf8mb4_bin DEFAULT 'pending',
`created_at` bigint(20) DEFAULT '0',
`processed_at` bigint(20) DEFAULT '0',
`remark` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.eb_item_sn
CREATE TABLE IF NOT EXISTS `web_item_sn` (
`sn` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`item_type` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`owner` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`status` varchar(500) COLLATE utf8mb4_bin DEFAULT 'active',
`issue_time` bigint(20) DEFAULT '0',
`loc_type` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`loc_player` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`loc_world` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`loc_x` bigint(20) DEFAULT '0',
`loc_y` bigint(20) DEFAULT '0',
`loc_z` bigint(20) DEFAULT '0',
`container_type` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`in_land` bigint(20) DEFAULT '0',
`land_name` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`last_seen` bigint(20) DEFAULT '0',
`cancel_time` bigint(20) DEFAULT '0',
`lost_count` bigint(20) DEFAULT '0',
`lost_state` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`remark` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`bind_reason` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`bind_time` bigint(20) DEFAULT '0',
`updated_at` bigint(20) DEFAULT '0',
`detached_at` bigint(20) DEFAULT '0',
`own_chest` bigint(20) DEFAULT '0',
PRIMARY KEY (`sn`),
KEY `idx_web_item_sn_owner` (`owner`(191),`item_type`(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.eb_land_owner_changes
CREATE TABLE IF NOT EXISTS `web_land_owner_changes` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`land_id` bigint(20) NOT NULL,
`land_name` text COLLATE utf8mb4_bin NOT NULL,
`old_owner` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`new_owner` text COLLATE utf8mb4_bin NOT NULL,
`created_at` bigint(20) DEFAULT '0',
`synced` bigint(20) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.eb_land_transfers
CREATE TABLE IF NOT EXISTS `web_land_transfers` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`land_name` text COLLATE utf8mb4_bin NOT NULL,
`old_owner` text COLLATE utf8mb4_bin NOT NULL,
`new_owner` text COLLATE utf8mb4_bin NOT NULL,
`status` varchar(500) COLLATE utf8mb4_bin DEFAULT 'pending',
`created_at` bigint(20) DEFAULT '0',
`completed_at` bigint(20) DEFAULT '0',
`expires_at` bigint(20) DEFAULT '0',
`cooldown_until` bigint(20) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.eb_sn_commands
CREATE TABLE IF NOT EXISTS `web_sn_commands` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`cmd` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`sn` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`player` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`item_type` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`reason` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`is_force` bigint(20) DEFAULT '0',
`link_type` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`link_id` bigint(20) DEFAULT '0',
`status` varchar(500) COLLATE utf8mb4_bin DEFAULT 'pending',
`result` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`created_at` bigint(20) DEFAULT '0',
`sent_at` bigint(20) DEFAULT '0',
`done_at` bigint(20) DEFAULT '0',
PRIMARY KEY (`id`),
KEY `idx_web_sn_cmd_status` (`status`,`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.eb_sn_cooldown
CREATE TABLE IF NOT EXISTS `web_sn_cooldown` (
`player` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`until` bigint(20) DEFAULT '0',
PRIMARY KEY (`player`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.eb_sn_event
CREATE TABLE IF NOT EXISTS `web_sn_event` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`action` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`sn` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`player` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`detail` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`created_at` bigint(20) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.eb_sn_held
CREATE TABLE IF NOT EXISTS `web_sn_held` (
`sn` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`player` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`holder` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`owner` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`item_type` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`held` bigint(20) DEFAULT '0',
`time` bigint(20) DEFAULT '0',
PRIMARY KEY (`sn`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.eb_sn_log
CREATE TABLE IF NOT EXISTS `web_sn_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`sn` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`item_type` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`action` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`player` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`detail` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`time` bigint(20) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=372 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.eb_sn_lost
CREATE TABLE IF NOT EXISTS `web_sn_lost` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`sn` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`player` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`reason` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`status` varchar(500) COLLATE utf8mb4_bin DEFAULT 'checking',
`result` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`report_count` bigint(20) DEFAULT '1',
`created_at` bigint(20) DEFAULT '0',
`handled_at` bigint(20) DEFAULT '0',
PRIMARY KEY (`id`),
KEY `idx_web_sn_lost_sn` (`sn`,`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.eb_sn_stock
CREATE TABLE IF NOT EXISTS `web_sn_stock` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`sn` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`item_type` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`action` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`world` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`x` bigint(20) DEFAULT '0',
`y` bigint(20) DEFAULT '0',
`z` bigint(20) DEFAULT '0',
`container_type` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`in_land` bigint(20) DEFAULT '0',
`land_name` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`player` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`time` bigint(20) DEFAULT '0',
PRIMARY KEY (`id`),
KEY `idx_web_sn_stock_sn` (`sn`,`time`)
) ENGINE=InnoDB AUTO_INCREMENT=41 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.eb_ticket_replies
CREATE TABLE IF NOT EXISTS `web_ticket_replies` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`ticket_id` bigint(20) NOT NULL,
`sender` text COLLATE utf8mb4_bin NOT NULL,
`role` varchar(500) COLLATE utf8mb4_bin DEFAULT 'user',
`message` text COLLATE utf8mb4_bin NOT NULL,
`created_at` bigint(20) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_web_ticket_replies_ticket` (`ticket_id`),
KEY `idx_wtr_ticket` (`ticket_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.eb_tickets
CREATE TABLE IF NOT EXISTS `web_tickets` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`type` text COLLATE utf8mb4_bin NOT NULL,
`status` varchar(500) COLLATE utf8mb4_bin DEFAULT 'submitted',
`requester` text COLLATE utf8mb4_bin NOT NULL,
`assigned_to` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`title` text COLLATE utf8mb4_bin NOT NULL,
`description` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`images` varchar(500) COLLATE utf8mb4_bin DEFAULT '[]',
`reject_reason` varchar(500) COLLATE utf8mb4_bin DEFAULT '',
`created_at` bigint(20) NOT NULL,
`updated_at` bigint(20) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_web_tickets_requester` (`requester`(191)),
KEY `idx_web_tickets_status` (`status`),
KEY `idx_web_tickets_assigned` (`assigned_to`),
KEY `idx_wt_requester` (`requester`(191)),
KEY `idx_wt_status` (`status`),
KEY `idx_wt_assigned` (`assigned_to`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- aoyuanphp.eb_twofa_qr
CREATE TABLE IF NOT EXISTS `web_twofa_qr` (
`token` varchar(500) COLLATE utf8mb4_bin NOT NULL,
`player` text COLLATE utf8mb4_bin NOT NULL,
`otpauth` text COLLATE utf8mb4_bin NOT NULL,
`expires_at` bigint(20) NOT NULL,
PRIMARY KEY (`token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-104
View File
@@ -1,104 +0,0 @@
<?php
/**
* 账号冻结页面(玩家从「异地登录提醒」邮件点击进入)
* 验证 freeze_token → 标记账号冻结(frozen=1) → 写入 web_admin_changes(freeze)
* 由 Java 插件 pollAdminChanges 拉取后执行 Bukkit 原生封禁。
* 解冻需在 reset_password.php 修改密码后由 PHP 写 web_admin_changes(unfreeze)。
*/
require_once __DIR__ . '/core.php';
$token = $_GET['token'] ?? '';
$error = '';
$success = false;
$player = '';
$resetLink = '';
if (!$token) {
die('无效链接');
}
$db = getDB();
$stmt = $db->prepare(
"SELECT player_name, frozen FROM users WHERE freeze_token = :t");
$stmt->bindValue(':t', $token, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) {
die('链接无效或已使用');
}
$player = $row['player_name'];
if ($row['frozen'] == 1) {
// 已冻结,直接展示已冻结页
$success = true;
} else {
// 执行冻结
$db->exec("UPDATE users SET frozen = 1, freeze_token = '' WHERE player_name = "
. SQLite3::escapeString($player));
// 通知 Java 插件封禁该玩家(Bukkit 原生 ban)
$now = time();
$stmt2 = $db->prepare(
"INSERT INTO web_admin_changes
(change_type, target_id, target_name, change_data, created_at)
VALUES ('freeze', 0, :name, '{}', :now)");
$stmt2->bindValue(':name', $player, SQLITE3_TEXT);
$stmt2->bindValue(':now', $now, SQLITE3_INTEGER);
$stmt2->execute();
$success = true;
}
// 取一个有效改密 token 构造「去改密」链接
$stmt3 = $db->prepare(
"SELECT token FROM password_reset_tokens
WHERE player_name = :name AND expire_at > :now
ORDER BY expire_at DESC LIMIT 1");
$stmt3->bindValue(':name', $player, SQLITE3_TEXT);
$stmt3->bindValue(':now', time(), SQLITE3_INTEGER);
$r3 = $stmt3->execute()->fetchArray(SQLITE3_ASSOC);
if ($r3) {
$resetLink = getBaseUrl() . '/reset_password.php?token=' . $r3['token'];
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>账号已冻结 - Sdf1 Minecraft</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Tahoma, sans-serif;
background: linear-gradient(135deg, #2c3e50 0%, #4b6584 100%);
min-height: 100vh; display: flex; align-items: center;
justify-content: center; padding: 20px; }
.container { background: white; border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
max-width: 440px; width: 100%; padding: 40px; text-align: center; }
h1 { color: #e0533d; font-size: 26px; margin-bottom: 16px; }
.player { background: #f5f5f5; border-radius: 8px; padding: 14px;
margin: 20px 0; font-size: 18px; }
.player strong { color: #e0533d; }
.tip { color: #666; font-size: 14px; line-height: 1.7; margin: 12px 0; }
.btn { display: inline-block; width: 100%; padding: 14px;
background: linear-gradient(135deg, #e0533d, #c0392b);
color: white; border: none; border-radius: 8px;
font-size: 16px; font-weight: 600; text-decoration: none;
transition: transform 0.2s; }
.btn:hover { transform: translateY(-2px); }
</style>
</head>
<body>
<div class="container">
<h1>✅ 账号已冻结</h1>
<p class="tip">为防范账号被盗用,玩家 <b><?php echo htmlspecialchars($player); ?></b> 的账号已被临时冻结。</p>
<div class="player">玩家:<strong><?php echo htmlspecialchars($player); ?></strong></div>
<p class="tip">冻结期间将无法进入游戏。如需解冻,请<b>修改密码</b>,系统将自动解除冻结。</p>
<?php if ($resetLink): ?>
<a class="btn" href="<?php echo htmlspecialchars($resetLink); ?>">前往修改密码并解冻</a>
<?php else: ?>
<p class="tip">未找到有效的改密链接,请返回异地登录提醒邮件点击「修改密码」。</p>
<?php endif; ?>
</div>
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

-118
View File
@@ -1,118 +0,0 @@
<?php
require_once __DIR__ . '/security.php'; // 第四层:全局限流(2026-09-30)
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - 页面不存在</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0f;
color: #e0e0e0;
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
overflow: hidden;
}
.container {
text-align: center;
position: relative;
}
.glitch {
font-size: 120px;
font-weight: 900;
color: #ff2d55;
text-shadow: 0 0 10px #ff2d55, 0 0 40px #ff2d55, 0 0 80px #ff2d55;
animation: glitch 2s infinite;
position: relative;
}
.glitch::before, .glitch::after {
content: '404';
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
}
.glitch::before {
color: #0ff;
animation: glitch-top 3s infinite;
clip-path: polygon(0 0, 100% 0, 100% 33%, 0 33%);
text-shadow: 0 0 10px #0ff;
}
.glitch::after {
color: #f0f;
animation: glitch-bottom 2.5s infinite;
clip-path: polygon(0 67%, 100% 67%, 100% 100%, 0 100%);
text-shadow: 0 0 10px #f0f;
}
@keyframes glitch {
0%, 90%, 100% { transform: translate(0); }
92% { transform: translate(-5px, 2px); }
94% { transform: translate(5px, -2px); }
96% { transform: translate(-3px, -1px); }
98% { transform: translate(3px, 1px); }
}
@keyframes glitch-top {
0%, 85%, 100% { transform: translate(0); }
87% { transform: translate(3px, -2px); }
89% { transform: translate(-3px, 1px); }
}
@keyframes glitch-bottom {
0%, 88%, 100% { transform: translate(0); }
90% { transform: translate(-4px, 2px); }
93% { transform: translate(4px, -1px); }
}
.msg {
font-size: 18px;
color: #888;
margin-top: 30px;
letter-spacing: 2px;
}
.links {
margin-top: 40px;
display: flex;
gap: 20px;
justify-content: center;
}
.links a {
color: #ff2d55;
text-decoration: none;
padding: 10px 24px;
border: 1px solid #ff2d55;
border-radius: 6px;
transition: all 0.3s;
}
.links a:hover {
background: #ff2d55;
color: #fff;
box-shadow: 0 0 20px rgba(255,45,85,0.4);
}
.scanline {
position: fixed;
top: 0; left: 0; right: 0;
height: 2px;
background: rgba(255,255,255,0.03);
animation: scan 8s linear infinite;
}
@keyframes scan {
0% { top: 0; }
100% { top: 100vh; }
}
</style>
</head>
<body>
<div class="scanline"></div>
<div class="container">
<div class="glitch">404</div>
<div class="msg">页面不存在或已被移除</div>
<div class="links">
<a href="player.php">玩家商城</a>
<a href="admin_login.php">管理后台</a>
</div>
</div>
</body>
</html>
-1969
View File
File diff suppressed because it is too large Load Diff
-170
View File
@@ -1,170 +0,0 @@
<?php
/**
* debug 日志保留策略(2026-10-05)
*
* 规则(两条同时满足才算干净,达成任一条超标即触发清理):
* 1. 只保留最近 3 天的日志行(按行首 [Y-m-d H:i:s] 时间戳判断);
* 2. 文件总大小 ≤ 5MB。
* 清理时大小目标压到 4.5MB 水位线(90%),留 0.5MB 缓冲——否则压到 5MB 上限后
* 下一条日志写入即再次超标,会造成「每次写入都全量重建」的 I/O 放大。
*
* 设计要点:
* - 自包含:不依赖 core.php / config.php(captcha_guard、poller 等独立入口都能用);
* - 廉价预检:每次写入后只做 1 次 stat + 读前几行找最旧时间戳,命中才做全量清理,
* 绝大多数调用在预检处直接返回,开销微秒级;
* - append-only 假设:日志按时间追加,文件开头即最旧行;
* - 并发安全:清理期间 flock 日志文件排他(写入方 file_put_contents(LOCK_EX) 会短暂
* 等待),锁内先复查,避免多个请求排队重复重建;
* - 流式处理:逐行读写、临时文件中转,60MB+ 大文件也不会撑爆 memory_limit;
* - 无时间戳前缀的行(多行消息的续行)跟随上一行去留,避免留下孤儿行;
* - 临时文件放在同目录(db/ 已被 nginx 整目录 404 屏蔽,外网不可见)。
*
* 接入点:core.php:debugLog / orders.php 内联 debugLog / captcha_guard.php:cgLog
* / api/poller_online.php:debugLog(api/pay_poller.php 为无引用模板,不接入)。
*/
if (!function_exists('logMaintain')) {
/** 从行首解析 [Y-m-d H:i:s] → unix 时间戳;解析失败返回 0 */
function logMaintainLineTs($line) {
if (preg_match('/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/', $line, $m)) {
$ts = strtotime($m[1]);
return ($ts === false) ? 0 : $ts;
}
return 0;
}
/**
* 读取文件最旧的可解析时间戳(最多扫前 maxLines 行找第一个带时间戳的行)。
* 找不到返回 0(调用方视为「无法判定年龄」,不触发超龄清理)。
* @param resource $fh 已打开的文件句柄(会被 rewind)
*/
function logMaintainOldestTs($fh, $maxLines = 10) {
if (rewind($fh) === false) return 0;
for ($i = 0; $i < $maxLines; $i++) {
$line = fgets($fh);
if ($line === false) break;
$ts = logMaintainLineTs($line);
if ($ts > 0) return $ts;
}
return 0;
}
/**
* 重建日志:过滤掉 cutoff 之前的行;若剩余仍 > maxBytes,再从最旧端按行丢弃到 ≤ maxBytes。
* 调用方必须已持有 $fh 的排他锁。
* @param resource $fh 日志文件句柄(读写)
* @param string $logFile 日志路径(临时文件建在同目录)
* @return bool 成功 true
*/
function logMaintainRebuild($fh, $logFile, $maxBytes, $cutoff) {
$tmp = $logFile . '.tmp';
// ===== 第一遍:读原文件,过滤超龄行,写入临时文件 =====
$out = @fopen($tmp, 'wb');
if (!$out) return false;
if (rewind($fh) === false) { fclose($out); @unlink($tmp); return false; }
$keepPrev = true; // 无时间戳的续行跟随上一行;首行无时间戳时保守保留
$kept = 0;
while (($line = fgets($fh)) !== false) {
$ts = logMaintainLineTs($line);
if ($ts > 0) {
$keepPrev = ($ts >= $cutoff);
}
if ($keepPrev) {
fwrite($out, $line);
$kept += strlen($line);
}
}
fflush($out);
fclose($out);
clearstatcache(true, $tmp);
// ===== 第二步:剩余仍超水位线 → 从最旧端按整行丢弃到 ≤ 90% maxBytes =====
// 水位线(4.5MB)留 0.5MB 缓冲:若压到 5MB 上限,下一条日志写入即再次超标,
// 会导致「每次写入都全量重建」的 I/O 放大;留缓冲后常态每 ~2500 条日志才重建一次。
$target = (int) ($maxBytes * 9 / 10);
$skipBytes = ($kept > $target) ? ($kept - $target) : 0;
$in = @fopen($tmp, 'rb');
if (!$in) { @unlink($tmp); return false; }
if ($skipBytes > 0) {
$acc = 0;
// 逐行累计丢弃;按整行丢,最终大小 kept-acc 必 ≤ target
while ($acc < $skipBytes && ($line = fgets($in)) !== false) {
$acc += strlen($line);
}
}
// ===== 写回日志本体(仍持锁,写入方在锁外等待,不会丢)=====
if (ftruncate($fh, 0) === false) { fclose($in); @unlink($tmp); return false; }
rewind($fh);
while (!feof($in)) {
$chunk = fread($in, 1048576);
if ($chunk === false) break;
if ($chunk !== '') fwrite($fh, $chunk);
}
fflush($fh);
fclose($in);
@unlink($tmp);
return true;
}
/**
* 入口:日志写入后调用。任一条件超标(>5MB 或 含 3 天前的行)即清理,
* 清理到两个条件同时满足为止(最多 3 轮,正常第 1 轮后复查即通过)。
*
* @param string $logFile 日志文件绝对路径
* @param int $maxBytes 大小上限(默认 5MB)
* @param int $keepDays 保留天数(默认 3 天)
* @return bool true=干净(无需清理或已清理完成);false=清理失败
*/
function logMaintain($logFile, $maxBytes = 5242880, $keepDays = 3) {
if (!is_string($logFile) || $logFile === '') return true;
clearstatcache(true, $logFile);
if (!is_file($logFile)) return true;
$size = @filesize($logFile);
if ($size === false || $size <= 0) return true;
$cutoff = time() - $keepDays * 86400;
// ---- 廉价预检:两条都没超标就直接返回(绝大多数调用在这里结束)----
$need = ($size > $maxBytes);
if (!$need) {
$fh = @fopen($logFile, 'rb');
if ($fh) {
$oldest = logMaintainOldestTs($fh);
fclose($fh);
$need = ($oldest > 0 && $oldest < $cutoff);
}
}
if (!$need) return true;
// ---- 清理:排他锁内执行(写入方 file_put_contents(LOCK_EX) 短暂等待)----
$fh = @fopen($logFile, 'c+');
if (!$fh) return false;
if (!flock($fh, LOCK_EX)) { fclose($fh); return false; }
$ok = false;
for ($round = 0; $round < 3; $round++) {
// 锁内复查:可能别的进程刚清理完
$stat = fstat($fh);
$size = $stat ? (int) $stat['size'] : 0;
$oldest = logMaintainOldestTs($fh);
if ($size <= $maxBytes && !($oldest > 0 && $oldest < $cutoff)) {
$ok = true;
break;
}
if (!logMaintainRebuild($fh, $logFile, $maxBytes, $cutoff)) {
break; // 重建失败(如目录不可写)→ 解锁退出,下次写入重试
}
}
flock($fh, LOCK_UN);
fclose($fh);
clearstatcache(true, $logFile);
return $ok;
}
} // end if (!function_exists('logMaintain'))
-414
View File
@@ -1,414 +0,0 @@
<?php
require_once __DIR__ . '/security.php'; // 第四层:全局限流(2026-09-30)
// ★ 人机验证码(2026-10-02):按 config.php 的 CAPTCHA_PROVIDER 选择 CF/VA,密钥在 captcha_keys.php
require_once __DIR__ . '/captcha_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 - Web登录</title>
<style>
:root {
--bg: #0d1117; --card: #161b22; --border: #30363d;
--text: #e6edf3; --dim: #8b949e; --accent: #58a6ff;
--green: #3fb950; --red: #f85149; --yellow: #d29922;
}
* { 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; display: flex; justify-content: center; align-items: center;
}
.login-card {
background: var(--card); border: 1px solid var(--border);
border-radius: 12px; padding: 40px; width: 400px; max-width: 90%;
text-align: center;
}
.login-card h1 { color: var(--accent); margin-bottom: 8px; font-size: 24px; }
.login-card .subtitle { color: var(--dim); margin-bottom: 32px; font-size: 14px; }
.status-box {
padding: 20px; border-radius: 8px; margin-bottom: 24px;
font-size: 14px; line-height: 1.8;
}
.status-box.loading { background: rgba(88,166,255,0.1); border: 1px solid var(--accent); }
.status-box.success { background: rgba(63,185,80,0.1); border: 1px solid var(--green); }
.status-box.error { background: rgba(248,81,73,0.1); border: 1px solid var(--red); }
.spinner {
display: inline-block; width: 20px; height: 20px;
border: 2px solid var(--dim); border-top-color: var(--accent);
border-radius: 50%; animation: spin 0.8s linear infinite;
vertical-align: middle; margin-right: 8px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.btn {
padding: 12px 24px; border: none; border-radius: 6px;
cursor: pointer; font-size: 14px; font-weight: 600;
transition: all 0.2s; text-decoration: none; display: inline-block;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: #79c0ff; }
.btn-dim { background: var(--border); color: var(--dim); }
.btn-dim:hover { background: #484f58; color: var(--text); }
.actions { display: flex; gap: 8px; justify-content: center; }
.player-name { color: var(--green); font-weight: 600; font-size: 16px; }
</style>
</head>
<body>
<div class="login-card">
<h1>SDF1 Web登录</h1>
<p class="subtitle" id="subtitle">Token验证中...</p>
<!-- Token验证状态 -->
<div id="tokenSection">
<div id="statusBox" class="status-box loading">
<span class="spinner"></span> 正在验证登录Token...
</div>
</div>
<!-- 密码验证(Token通过后显示) -->
<div id="passwordSection" style="display:none">
<div id="pwdStatusBox" class="status-box" style="display:none"></div>
<div style="text-align:left;margin-bottom:16px">
<label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">玩家名</label>
<input type="text" id="playerName" placeholder="玩家名" readonly
style="width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box;opacity:0.7">
</div>
<!-- ★ 切换标签:密码/邮箱 -->
<div style="display:flex;gap:8px;margin-bottom:16px">
<button class="btn btn-primary" id="tabPassword" onclick="switchLoginTab('password')" style="flex:1">游戏密码</button>
<button class="btn btn-dim" id="tabEmail" onclick="switchLoginTab('email')" style="flex:1">邮箱验证码</button>
</div>
<!-- 密码登录 -->
<div id="loginPasswordTab">
<div style="text-align:left;margin-bottom:20px">
<label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">游戏内密码</label>
<input type="password" id="password" placeholder="输入游戏内密码" maxlength="32"
style="width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box">
<p style="color:var(--dim);font-size:12px;margin-top:8px;text-align:left">密码由游戏服务器验证,Web端不存储密码</p>
</div>
<?php echo cgWidgetHtml(); ?>
<button class="btn btn-primary" style="width:100%" onclick="doPasswordLogin()">登录到Web端</button>
</div>
<!-- 邮箱验证码登录 -->
<div id="loginEmailTab" style="display:none">
<div style="text-align:left;margin-bottom:12px">
<label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">绑定邮箱</label>
<input type="text" id="maskedEmail" readonly placeholder="加载中..."
style="width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box;opacity:0.7">
</div>
<div style="display:flex;gap:8px;margin-bottom:20px">
<input type="text" id="emailCode" placeholder="6位验证码" maxlength="6"
style="flex:1;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;outline:none;box-sizing:border-box">
<button class="btn btn-primary" id="sendCodeBtn" onclick="sendEmailCode()" style="white-space:nowrap">发送验证码</button>
</div>
<button class="btn btn-primary" style="width:100%" onclick="doEmailLogin()">登录到Web端</button>
</div>
</div>
<div class="actions" id="actions" style="display:none"></div>
</div>
<script>
const TOKEN = new URLSearchParams(location.search).get('token') || '';
const statusBox = document.getElementById('statusBox');
const actionsDiv = document.getElementById('actions');
const tokenSection = document.getElementById('tokenSection');
const passwordSection = document.getElementById('passwordSection');
const subtitle = document.getElementById('subtitle');
const pwdStatusBox = document.getElementById('pwdStatusBox');
// ===== 安全拦截:无Token则拒绝访问 =====
if (!TOKEN) {
document.body.innerHTML = `
<div style="text-align:center;padding:60px 20px;">
<h1 style="color:#f85149;margin-bottom:20px">🚫 访问被拒绝</h1>
<p style="color:var(--dim);font-size:16px;line-height:1.8">
Web登录必须通过游戏内Token验证。<br>
请在游戏中执行 <b style="color:var(--accent)"> <b>/web</b><br>
然后点击上方链接登录Web端。
</p>
</div>
`;
console.error('[登录页面] 无Token访问被拒绝');
throw new Error('NoToken');
}
// ===== 有Token → 验证Token =====
tokenSection.style.display = 'block';
doTokenLogin();
// ===== Token验证 =====
async function doTokenLogin() {
try {
const apiUrl = './api/sync.php?action=validate_weblogin_token';
const res = await fetch(apiUrl, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({web_token: TOKEN})
});
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch (parseErr) {
statusBox.className = 'status-box error';
statusBox.innerHTML = '❌ 服务器响应异常<br><br><code style="font-size:11px;word-break:break-all;color:var(--dim)">' + text.substring(0, 300) + '</code>';
actionsDiv.style.display = 'flex';
actionsDiv.innerHTML = '<span style="color:var(--dim);font-size:13px">请在游戏中重新执行 <b>/sdf1_login weblogin</b></span>';
return;
}
if (data.success && data.data) {
const player = data.data.player;
localStorage.setItem('sdf1_player', player);
localStorage.setItem('sdf1_token', TOKEN);
localStorage.setItem('sdf1_login_time', Date.now());
// ★ Token验证成功 → 直接跳转到player.php(跳过密码,token本身即身份证明)
statusBox.className = 'status-box success';
statusBox.innerHTML = '✅ Token验证成功!<br><br>欢迎, <span class="player-name">' + player + '</span><br><small style="color:var(--dim)">正在跳转到玩家商城...</small>';
// ★ 同时写入web_session_log(让validateWebAccess能识别在线状态)
fetch('./api/sync.php?action=web_access_check', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({web_token: TOKEN, access_action: 'view'})
}).catch(() => {});
setTimeout(() => {
// ★ 跳转时带上token,确保player.php一定能读到
window.location.href = 'player.php?login=' + encodeURIComponent(player) + '&token=' + encodeURIComponent(TOKEN);
}, 800);
} else {
statusBox.className = 'status-box error';
statusBox.innerHTML = '❌ 登录失败<br><br>' + (data.message || 'Token无效或已过期');
actionsDiv.style.display = 'flex';
actionsDiv.innerHTML = '<span style="color:var(--dim);font-size:13px">请在游戏中重新执行 <b>/sdf1_login weblogin</b></span>';
}
} catch (e) {
statusBox.className = 'status-box error';
statusBox.innerHTML = '❌ 连接失败<br><br>' + e.message;
}
}
// ===== 密码登录 =====
async function doPasswordLogin() {
const player = document.getElementById('playerName').value.trim();
const password = document.getElementById('password').value;
if (!password) { showPwdError('请输入密码'); return; }
// ★ 人机验证(2026-10-02):CF 渲染完即有 token(基本无感);VA 没做会在这里拉起验证
let cap = {};
if (window.CaptchaGuard) {
try { cap = await CaptchaGuard.payload(); }
catch (e) { showPwdError(e.message || '请先完成人机验证'); return; }
}
pwdStatusBox.className = 'status-box loading';
pwdStatusBox.style.display = 'block';
pwdStatusBox.innerHTML = '<span class="spinner"></span> 正在验证密码...';
try {
// 1. 提交登录请求
const apiUrl = './api/sync.php';
const reqRes = await fetch(apiUrl + '?action=web_login_request', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(Object.assign({player: player, password: password}, cap))
});
const reqData = await reqRes.json();
if (!reqData.success) {
showPwdError(reqData.message || '提交失败');
return;
}
const requestId = reqData.data.request_id;
// 2. 轮询结果
pwdStatusBox.innerHTML = '<span class="spinner"></span> 等待游戏服务器验证密码...';
let attempts = 0;
const maxAttempts = 60; // 最多等60秒
const pollInterval = setInterval(async () => {
attempts++;
if (attempts > maxAttempts) {
clearInterval(pollInterval);
showPwdError('验证超时,请稍后重试');
return;
}
try {
const pollRes = await fetch(apiUrl + '?action=check_web_login_result&player=' + encodeURIComponent(player) + '&request_id=' + requestId);
const pollText = await pollRes.text();
const pollData = JSON.parse(pollText);
console.log('[poll] status:', pollData.status || pollData.data?.status, 'data:', JSON.stringify(pollData.data).substring(0, 100));
if (pollData.success && pollData.data) {
const result = pollData.data;
if (result.status === 'success') {
clearInterval(pollInterval);
// 登录成功 → 进入玩家商城
pwdStatusBox.className = 'status-box success';
pwdStatusBox.innerHTML = '✅ 登录成功!<br><br>欢迎, <span class="player-name">' + player + '</span><br><small style="color:var(--dim)">正在跳转...</small>';
// ★ token已存入localStorage,跳转player.php带token参数
setTimeout(() => {
window.location.href = 'player.php?login=' + encodeURIComponent(player) + '&token=' + encodeURIComponent(TOKEN);
}, 500);
} else if (result.status === 'failed') {
clearInterval(pollInterval);
showPwdError(result.message || '密码错误');
}
// status === 'pending' → 继续轮询
} else if (pollData.data && pollData.data.status === 'pending') {
// 仍在pending状态,可能是请求过期,继续轮询
console.log('[poll] 请求pending或过期,继续等待...');
} else {
console.warn('[poll] 轮询响应异常:', JSON.stringify(pollData).substring(0, 200));
}
} catch (pollErr) {
console.error('[poll] 轮询异常:', pollErr);
}
}, 1000);
} catch (e) {
showPwdError('连接失败: ' + e.message);
}
}
function showPwdError(msg) {
pwdStatusBox.className = 'status-box error';
pwdStatusBox.style.display = 'block';
pwdStatusBox.innerHTML = '❌ ' + msg;
}
// 回车提交
document.getElementById('password').addEventListener('keydown', function(e) {
if (e.key === 'Enter') doPasswordLogin();
});
document.getElementById('emailCode').addEventListener('keydown', function(e) {
if (e.key === 'Enter') doEmailLogin();
});
// ===== 标签切换 =====
let currentLoginTab = 'password';
function switchLoginTab(tab) {
currentLoginTab = tab;
document.getElementById('loginPasswordTab').style.display = tab === 'password' ? 'block' : 'none';
document.getElementById('loginEmailTab').style.display = tab === 'email' ? 'block' : 'none';
document.getElementById('tabPassword').className = tab === 'password' ? 'btn btn-primary' : 'btn btn-dim';
document.getElementById('tabEmail').className = tab === 'email' ? 'btn btn-primary' : 'btn btn-dim';
document.getElementById('pwdStatusBox').style.display = 'none';
// 切到邮箱时加载脱敏邮箱
if (tab === 'email') loadMaskedEmail();
}
// ===== 加载脱敏邮箱 =====
async function loadMaskedEmail() {
const player = document.getElementById('playerName').value;
try {
const url = new URL('./api/sync.php', location.href);
url.searchParams.set('action', 'get_player_email');
url.searchParams.set('player', player);
const res = await fetch(url, { method: 'POST', headers: {'Content-Type': 'application/json'} });
const data = await res.json();
if (data.success && data.data) {
document.getElementById('maskedEmail').value = data.data.masked_email || '未绑定邮箱';
} else {
document.getElementById('maskedEmail').value = '未绑定邮箱';
}
} catch (e) {
document.getElementById('maskedEmail').value = '加载失败';
}
}
// ===== 发送邮箱验证码 =====
async function sendEmailCode() {
const player = document.getElementById('playerName').value;
const btn = document.getElementById('sendCodeBtn');
btn.disabled = true;
btn.textContent = '发送中...';
try {
const url = new URL('./api/sync.php', location.href);
url.searchParams.set('action', 'send_email_code');
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({player: player, web_token: TOKEN})
});
const data = await res.json();
if (data.success) {
showPwdSuccess('验证码已发送至 ' + (data.data.masked_email || '**'));
let countdown = 60;
const timer = setInterval(() => {
btn.textContent = countdown + '秒后重发';
countdown--;
if (countdown <= 0) {
clearInterval(timer);
btn.disabled = false;
btn.textContent = '发送验证码';
}
}, 1000);
} else {
showPwdError(data.message || '发送失败');
btn.disabled = false;
btn.textContent = '发送验证码';
}
} catch (e) {
showPwdError('连接失败: ' + e.message);
btn.disabled = false;
btn.textContent = '发送验证码';
}
}
// ===== 邮箱验证码登录 =====
async function doEmailLogin() {
const player = document.getElementById('playerName').value;
const code = document.getElementById('emailCode').value.trim();
if (!code) { showPwdError('请输入验证码'); return; }
if (code.length !== 6) { showPwdError('验证码为6位数字'); return; }
pwdStatusBox.className = 'status-box loading';
pwdStatusBox.style.display = 'block';
pwdStatusBox.innerHTML = '<span class="spinner"></span> 正在验证邮箱验证码...';
try {
const url = new URL('./api/sync.php', location.href);
url.searchParams.set('action', 'verify_email_code');
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({player: player, code: code, web_token: TOKEN})
});
const data = await res.json();
if (data.success) {
localStorage.setItem('sdf1_player', player);
localStorage.setItem('sdf1_token', TOKEN);
pwdStatusBox.className = 'status-box success';
pwdStatusBox.innerHTML = '✅ 邮箱验证成功!<br><br>欢迎, <span class="player-name">' + player + '</span><br><small style="color:var(--dim)">正在跳转...</small>';
setTimeout(() => {
window.location.href = 'player.php?login=' + encodeURIComponent(player);
}, 500);
} else {
showPwdError(data.message || '验证失败');
}
} catch (e) {
showPwdError('连接失败: ' + e.message);
}
}
function showPwdSuccess(msg) {
pwdStatusBox.className = 'status-box success';
pwdStatusBox.style.display = 'block';
pwdStatusBox.innerHTML = '✅ ' + msg;
}
</script>
</body>
</html>
-165
View File
@@ -1,165 +0,0 @@
<?php
/**
* 兰空图床(Lsky Pro)API 客户端 —— 工单图片上传专用
*
* 接口规格(图床官方 API 文档,站点 https://img.ypshidifu.cn/api/v1/):
* POST /tokens body {email,password} -> {status, data:{token, expires_at}}
* POST /upload multipart: file(+strategy_id)
* Header: Authorization: Bearer <token>
* -> {status, data:{key,links:{url,html,markdown,...},thumbnail_url,...}}
*
* 设计要点:
* - 凭据在 lsky_keys.php(仓库只有空模板,部署脚本永不上传);
* - token 有有效期 -> 文件缓存 + 提前 60 秒自动续签 + 上传遇 401 自动强刷重签重试 1 次;
* - 本文件不含任何敏感值,可入库、可批量部署。
*
* ★ 依赖 core.php 的 error()/success()(本文件只被 api/ticket.php 引入,core 必然已加载)。
*/
// 图床前缀白名单:写库前校验图片 URL 只能来自自家图床(防存任意 URL)。
if (!defined('LSKY_ALLOWED_HOST_PREFIX')) {
define('LSKY_ALLOWED_HOST_PREFIX', 'https://img.ypshidifu.cn/');
}
/** 加载 lsky_keys.php(只加载一次),并检查必填项 */
function lskyLoadKeys() {
static $done = false;
if ($done) return;
$file = __DIR__ . '/lsky_keys.php';
if (!file_exists($file)) {
error('图床配置缺失:服务器上没有 lsky_keys.php(请参照仓库模板放置)', 500);
}
require_once $file;
$done = true;
if (!defined('LSKY_API_URL') || !defined('LSKY_EMAIL') || !defined('LSKY_PASSWORD')) {
error('图床配置不完整:LSKY_API_URL / LSKY_EMAIL / LSKY_PASSWORD 必须定义', 500);
}
}
/**
* 底层 HTTP(curl)。$isMultipart=true 时 $payload 直接作为 CURLOPT_POSTFIELDS 数组
* (boundary 交给 curl 自动处理,切勿手写 Content-Type)。
* 返回 ['ok','status','msg','data']。
*/
function lskyCurlJson($method, $url, $payload = null, $token = null, $isMultipart = false) {
if (!function_exists('curl_init')) {
return ['ok' => false, 'status' => 0, 'msg' => '服务器缺少 PHP curl 扩展', 'data' => null];
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
$headers = ['Accept: application/json'];
if ($token !== null && $token !== '') $headers[] = 'Authorization: Bearer ' . $token;
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
if ($isMultipart) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); // 数组 -> multipart
} else {
$json = json_encode($payload === null ? new stdClass() : $payload);
curl_setopt($ch, CURLOPT_POSTFIELDS, $json);
$headers[] = 'Content-Type: application/json';
}
} else {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$err = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($errno) {
return ['ok' => false, 'status' => 0, 'msg' => 'cURL错误: ' . $err, 'data' => null];
}
$j = json_decode((string)$body, true);
return [
'ok' => ($status >= 200 && $status < 300),
'status' => $status,
'msg' => (is_array($j) && isset($j['message']) && $j['message'] !== '')
? (string)$j['message']
: ('HTTP ' . $status . ' ' . substr((string)$body, 0, 160)),
'data' => (is_array($j) && isset($j['data']) && is_array($j['data'])) ? $j['data'] : null,
];
}
/**
* 获取 API token:缓存优先(提前 60 秒过期),失效则 POST /tokens 续签并落盘。
* 续签失败但缓存里还有旧 token 时返回旧 token(图床临时抖动不至于让上传全挂)。
*/
function lskyGetToken($force = false) {
lskyLoadKeys();
$cache = defined('LSKY_TOKEN_CACHE') ? LSKY_TOKEN_CACHE : (__DIR__ . '/db/lsky_token.json');
$cached = null;
if (!$force && is_readable($cache)) {
$cached = json_decode((string)@file_get_contents($cache), true);
if (is_array($cached) && !empty($cached['token'])
&& isset($cached['expire_at']) && time() < (int)$cached['expire_at'] - 60) {
return $cached['token'];
}
}
$r = lskyCurlJson('POST', rtrim(LSKY_API_URL, '/') . '/tokens',
['email' => LSKY_EMAIL, 'password' => LSKY_PASSWORD]);
if ($r['ok'] && !empty($r['data']['token'])) {
$token = (string)$r['data']['token'];
$expire = time() + 3600; // 响应没有 expires_at 时按 1 小时兜底
if (!empty($r['data']['expires_at'])) {
$ts = strtotime((string)$r['data']['expires_at']);
if ($ts !== false && $ts > time()) $expire = $ts;
}
$dir = dirname($cache);
if (!is_dir($dir)) @mkdir($dir, 0755, true);
@file_put_contents($cache,
json_encode(['token' => $token, 'expire_at' => $expire, 'cached_at' => time()]),
LOCK_EX);
return $token;
}
// 续签失败 -> 旧缓存顶着用
if (is_array($cached) && !empty($cached['token'])) return $cached['token'];
return null;
}
/**
* 上传一张已通过校验的图片到图床。
* 401(token 失效)自动强刷 token 重试 1 次。
* 成功返回 ['ok'=>true,'url'=>..,'thumb'=>..,'key'=>..],失败 ['ok'=>false,'msg'=>..]
*/
function lskyUploadFile($tmpPath, $origName) {
lskyLoadKeys();
if (!is_uploaded_file($tmpPath)) {
return ['ok' => false, 'msg' => '非法上传(is_uploaded_file 校验失败)'];
}
$mime = 'application/octet-stream';
$info = @getimagesize($tmpPath);
if (!empty($info['mime'])) $mime = (string)$info['mime'];
for ($try = 0; $try < 2; $try++) {
$token = lskyGetToken($try > 0);
if (!$token) {
return ['ok' => false, 'msg' => '获取图床 token 失败(请检查 lsky_keys.php 的邮箱密码)'];
}
$fields = ['file' => new CURLFile($tmpPath, $mime, $origName)];
if (defined('LSKY_STRATEGY_ID') && (int)LSKY_STRATEGY_ID > 0) {
$fields['strategy_id'] = (int)LSKY_STRATEGY_ID;
}
$r = lskyCurlJson('POST', rtrim(LSKY_API_URL, '/') . '/upload', $fields, $token, true);
if ($r['ok'] && !empty($r['data']['links']['url'])) {
$url = (string)$r['data']['links']['url'];
$thumb = !empty($r['data']['thumbnail_url']) ? (string)$r['data']['thumbnail_url'] : $url;
return ['ok' => true, 'url' => $url, 'thumb' => $thumb,
'key' => isset($r['data']['key']) ? (string)$r['data']['key'] : ''];
}
if ($r['status'] !== 401) {
return ['ok' => false, 'msg' => $r['msg']];
}
// 401 -> 第 2 轮由 lskyGetToken(true) 强制续签后重试
}
return ['ok' => false, 'msg' => '上传失败(token 无效或已过期)'];
}
-25
View File
@@ -1,25 +0,0 @@
<?php
/**
* 兰空图床(Lsky Pro)API 配置 —— 工单图片上传用
*
* ★ 本文件仓库版是【空模板】:真实邮箱/密码只在服务器上手工放置,
* deploy_php_only.py 的 EXCLUDE_PATTERNS 永不上传本文件(覆盖线上即丢真值)。
* 与 captcha_keys.php / config.php / pay_secrets.php 同一套规矩。
*
* ★ 图床地址(接口文档确认):https://img.ypshidifu.cn/api/v1/
* 取 token : POST /tokens body {email,password} -> data.token
* 上传 : POST /upload multipart: file(+strategy_id), Header: Authorization: Bearer <token>
*
* 服务器版真值示例(勿写进仓库):
* define('LSKY_EMAIL', '真实邮箱');
* define('LSKY_PASSWORD', '真实密码');
*/
define('LSKY_API_URL', 'https://img.ypshidifu.cn/api/v1');
define('LSKY_EMAIL', 'REPLACE_ME_LSKY_EMAIL');
define('LSKY_PASSWORD', 'REPLACE_ME_LSKY_PASSWORD');
// 上传走的存储策略ID:1 = 默认本地策略(接口文档 GET /strategies 返回的 id;0 = 不传该参数)
define('LSKY_STRATEGY_ID', 1);
// token 缓存文件(web.db 同目录,Web 可写;含有效期内的 token,仅服务器本地可读)
define('LSKY_TOKEN_CACHE', __DIR__ . '/db/lsky_token.json');
-256
View File
@@ -1,256 +0,0 @@
<?php
require_once __DIR__ . '/core.php';
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
if (session_status() === PHP_SESSION_NONE) session_start();
$action = getParam('action', 'page');
// ===== 快照采集已上收 core.php(3.11内测版)=====
// 全站唯一采集函数 onlineCurveEnsureSnapshot() 由 core.php 提供:
// sync.php 三个入口(sync_online_players / online_heartbeat / 对账兜底) + 本页面
// 多路触发统一写入,60秒节流,心跳判据(>120秒记0防停服假曲线),保留7天。
// 本地三个函数改薄壳保持旧调用点兼容。
function ensureSnapshotTable() {
onlineCurveEnsureSnapshot(false, 'page');
}
// ===== 记录快照(薄壳:是否真正落样本由 core 60秒节流裁决) =====
function recordSnapshot() {
$r = onlineCurveEnsureSnapshot(false, 'page');
return $r['count'];
}
// ===== 确保有最新快照(薄壳:节流由 core 统一处理) =====
function ensureRecentSnapshot() {
$r = onlineCurveEnsureSnapshot(false, 'page');
return $r['recorded'] ? $r['count'] : null;
}
// ===== 获取24小时聚合数据(按小时) =====
function getHourlyData() {
ensureSnapshotTable();
// 每次查询数据时自动确保有最新快照(如果超过60秒未记录)
ensureRecentSnapshot();
// 24小时前的时间戳
$since = time() - 86400;
// 按自然小时聚合:分桶到 h0~h23,取平均值
$buckets = [];
for ($i = 0; $i < 24; $i++) $buckets[$i] = ['hour' => $i, 'count' => 0, 'samples' => 0, 'max' => 0];
try {
$db = getDB();
$stmt = $db->prepare("SELECT snapshot_time, online_count FROM online_snapshots WHERE snapshot_time >= :since ORDER BY snapshot_time ASC");
$stmt->bindValue(':since', $since, SQLITE3_INTEGER);
$res = $stmt->execute();
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
$snapshotTime = (int)$row['snapshot_time'];
// 使用本地时区获取小时(0-23)
$h = (int)date('G', $snapshotTime);
$buckets[$h]['count'] += (int)$row['online_count'];
$buckets[$h]['samples']++;
if ((int)$row['online_count'] > $buckets[$h]['max']) $buckets[$h]['max'] = (int)$row['online_count'];
}
} catch (\Throwable $e) {
debugLog('[online_curve] 查询异常: ' . $e->getMessage());
}
$result = [];
foreach ($buckets as $b) {
$avg = $b['samples'] > 0 ? round($b['count'] / $b['samples'], 1) : 0;
$result[] = [
'hour' => sprintf('%02d:00', $b['hour']),
'avg' => $avg,
'max' => $b['max'],
'samples' => $b['samples'],
];
}
return ['buckets' => $result, 'total_snapshots' => array_sum(array_column($result, 'samples'))];
}
// ===== 路由 =====
switch ($action) {
case 'record':
$c = recordSnapshot();
success(['recorded' => true, 'online_count' => $c]);
exit;
case 'data':
success(getHourlyData());
exit;
case 'page':
default:
// 页面加载时也记录一次快照
$currentOnline = recordSnapshot();
$initialState = json_encode(array_merge(
['current_online' => $currentOnline],
getHourlyData()
), JSON_UNESCAPED_UNICODE);
break;
}
?>
<!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 - 24小时在线曲线</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<style>
:root { --bg:#0d1117; --card:#161b22; --border:#30363d; --text:#e6edf3; --dim:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d29922; --purple:#bc8cff; --chart-grid:#30363d; }
* { 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:960px; margin:24px auto; padding:0 16px; }
.stat-row { display:flex; gap:14px; margin-bottom:20px; flex-wrap:wrap; }
.stat-card { flex:1; min-width:140px; background:var(--card); border:1px solid var(--border); border-radius:12px; padding:16px; text-align:center; }
.stat-card .val { font-size:26px; font-weight:700; color:var(--accent); }
.stat-card .lbl { font-size:12px; color:var(--dim); margin-top:4px; }
.chart-wrap { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:20px; position:relative; }
.chart-wrap h3 { font-size:15px; color:var(--accent); margin-bottom:14px; }
.chart-wrap canvas { max-height:360px; }
.empty-chart { text-align:center; padding:60px 20px; color:var(--dim); }
.empty-chart .msg { font-size:16px; margin-bottom:8px; }
.empty-chart .sub { font-size:13px; }
.refresh-hint { text-align:center; font-size:12px; color:var(--dim); margin-top:12px; }
#loading { text-align:center; padding:40px; color:var(--dim); }
@media(max-width:600px) {
.stat-card { min-width:calc(50% - 7px); }
}
</style>
</head>
<body>
<div class="header">
<h1>📈 24小时在线曲线</h1>
<div class="info">每60秒刷新 · Java同步自动采集</div>
</div>
<div class="container">
<div id="loading">加载中...</div>
<div id="content" style="display:none">
<div class="stat-row">
<div class="stat-card"><div class="val" id="curOnline">0</div><div class="lbl">当前在线</div></div>
<div class="stat-card"><div class="val" id="totalSnap">0</div><div class="lbl">快照样本数</div></div>
<div class="stat-card"><div class="val" id="peakHour">-</div><div class="lbl">高峰时段</div></div>
<div class="stat-card"><div class="val" id="peakAvg">0</div><div class="lbl">高峰均值</div></div>
</div>
<div class="chart-wrap">
<h3>各时段平均在线人数</h3>
<div id="chartContainer">
<canvas id="curveChart"></canvas>
</div>
<div id="emptyHint" class="empty-chart" style="display:none">
<div class="msg">📊 数据收集中</div>
<div class="sub">Java 插件同步/心跳每60秒自动采集(停服时段记0防假数据),积累后自动生成曲线</div>
</div>
</div>
<div class="refresh-hint" id="refreshHint"></div>
</div>
</div>
<script>
const state = <?= $initialState ?>;
let chart = null;
function renderChart(buckets) {
const hasData = buckets.some(b => b.samples > 0);
const ctx = document.getElementById('curveChart').getContext('2d');
const labels = buckets.map(b => b.hour);
const values = buckets.map(b => b.avg);
document.getElementById('emptyHint').style.display = hasData ? 'none' : 'block';
if (chart) { chart.destroy(); chart = null; }
const isDark = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() === '#0d1117';
const textColor = isDark ? '#e6edf3' : '#1f2328';
const gridColor = isDark ? '#30363d' : '#d0d7de';
if (!hasData) {
document.getElementById('curveChart').style.display = 'none';
return;
}
document.getElementById('curveChart').style.display = 'block';
chart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: '平均在线',
data: values,
backgroundColor: 'rgba(88,166,255,0.55)',
borderColor: '#58a6ff',
borderWidth: 1,
borderRadius: 3,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: textColor, font: { size: 12 } } },
tooltip: {
backgroundColor: isDark ? '#161b22' : '#fff',
titleColor: textColor,
bodyColor: textColor,
borderColor: isDark ? '#30363d' : '#d0d7de',
borderWidth: 1,
callbacks: {
afterBody: function(ctx) {
const i = ctx[0].dataIndex;
const s = buckets[i].samples;
return '采样数: ' + s;
}
}
}
},
scales: {
x: {
ticks: { color: textColor, font: { size: 10 }, maxRotation: 45 },
grid: { color: gridColor }
},
y: {
beginAtZero: true,
ticks: { color: textColor, font: { size: 11 }, stepSize: 1 },
grid: { color: gridColor }
}
}
}
});
}
function render(data) {
document.getElementById('loading').style.display = 'none';
document.getElementById('content').style.display = 'block';
document.getElementById('curOnline').textContent = data.current_online;
document.getElementById('totalSnap').textContent = data.total_snapshots;
// 找高峰时段
let peakIdx = 0, peakVal = 0;
data.buckets.forEach((b, i) => {
if (b.avg > peakVal) { peakVal = b.avg; peakIdx = i; }
});
document.getElementById('peakHour').textContent = peakVal > 0 ? data.buckets[peakIdx].hour : '-';
document.getElementById('peakAvg').textContent = peakVal > 0 ? peakVal.toFixed(1) : '0';
renderChart(data.buckets);
document.getElementById('refreshHint').textContent = '更新:' + new Date().toLocaleTimeString('zh-CN', {hour12:false});
}
render(state);
// 每60秒刷新 + 记录快照
setInterval(() => {
fetch('online_curve.php?action=record&_t=' + Date.now()).then(r=>r.json()).then(d => {
if (d.success) {
document.getElementById('curOnline').textContent = d.data.online_count;
}
}).catch(()=>{});
fetch('online_curve.php?action=data&_t=' + Date.now()).then(r=>r.json()).then(d => {
if (d.success) render(Object.assign(d.data, {current_online: parseInt(document.getElementById('curOnline').textContent) || 0}));
}).catch(()=>{});
}, 60000);
</script>
</body>
</html>
-313
View File
@@ -1,313 +0,0 @@
<?php
/**
* 独立订单记录页面
* 从收银台拆分出来,支持搜索、筛选、完整订单详情查看。
*/
// ★ 错误报告增强:捕获所有PHP错误写入debug.log(包括warning/notice)
// 用户反馈"全新无扩展浏览器仍报错",需排查任何可能破坏前端输出的PHP问题
error_reporting(E_ALL);
ini_set('display_errors', '0');
set_error_handler(function($errno, $errstr, $errfile, $errline) {
$types = [E_WARNING=>'Warning',E_NOTICE=>'Notice',E_USER_ERROR=>'UserError',E_USER_WARNING=>'UserWarning',E_USER_NOTICE=>'UserNotice',E_STRICT=>'Strict',E_DEPRECATED=>'Deprecated',E_RECOVERABLE_ERROR=>'Recoverable'];
$type = isset($types[$errno]) ? $types[$errno] : "Error[$errno]";
if (file_exists(__DIR__ . '/core.php')) {
// core.php may not be loaded yet, define minimal debugLog
if (!function_exists('debugLog')) {
function debugLog($msg) {
$lf = __DIR__ . '/db/debug.log';
@file_put_contents($lf, "[" . date('Y-m-d H:i:s') . "] [orders.php] $msg\n", 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($lf); }
}
}
}
debugLog("[orders.php] PHP {$type}: {$errstr} in {$errfile}:{$errline}");
}
return true; // 不阻止默认错误处理
});
require_once __DIR__ . '/core.php';
// ★ 强制使 opcache 失效,确保部署后的新版(含请求超时/重试)立即生效,
// 避免 Web 服务端 opcache 持续服务旧版 orders.php(无超时→永久"加载中")
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
if (session_status() === PHP_SESSION_NONE) session_start();
// ★ 输出缓冲:捕获任何前置输出(PHP warning/notice/BOM等),防止破坏前端JSON解析
$preOutput = '';
if (ob_get_level() > 0) {
$preOutput = ob_get_clean();
}
if (!empty($preOutput)) {
// 记录但不阻断:有前置输出说明某处产生了warning/notice/BOM
debugLog('[orders.php] 检测到前置输出(' . strlen($preOutput) . ' bytes): ' . substr(trim($preOutput), 0, 500));
}
try {
$loggedIn = isAdminLoggedIn() || isCashierLoggedIn();
$role = 'guest';
$name = '';
if (isAdminLoggedIn()) { $role = 'admin'; $name = 'admin'; }
elseif (isCashierLoggedIn()) { $c = getCurrentCashier() ?: []; $role = 'cashier'; $name = $c['username'] ?? ''; }
} catch (\Throwable $e) {
debugLog('[orders.php] 会话状态判定异常: ' . $e->getMessage());
$loggedIn = false; $role = 'guest'; $name = '';
}
$initialState = json_encode([
'logged_in' => $loggedIn,
'role' => $role,
'username' => $name,
], 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, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, proxy-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<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); }
.btn { padding:8px 16px; border:none; border-radius:6px; cursor:pointer; font-size:13px; font-weight:600; transition:all .2s cubic-bezier(.16,1,.3,1); }
.btn-blue { background:var(--accent); color:#fff; }
.btn-green { background:var(--green); color:#fff; }
.btn-red { background:var(--red); color:#fff; }
.btn-ghost { background:transparent; border:1px solid var(--border); color:var(--dim); }
.btn:hover { opacity:.88; transform:translateY(-1px); }
.container { max-width:1100px; margin:20px auto; padding:0 16px; }
.panel { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:18px; margin-bottom:16px; }
.panel h3 { font-size:15px; color:var(--accent); margin-bottom:12px; display:flex; align-items:center; gap:8px; }
/* 搜索栏 */
.search-bar { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:16px; }
.search-bar input, .search-bar select { padding:9px 12px; background:var(--bg); border:1px solid var(--border); border-radius:7px; color:var(--text); font-size:13px; outline:none; flex:1; min-width:160px; }
.search-bar input:focus { border-color:var(--accent); }
/* 订单列表 */
.order-row { font-size:13px; padding:14px; background:var(--bg); border:1px solid var(--border); border-radius:10px; margin-bottom:10px; transition:border-color .2s; cursor:pointer; }
.order-row:hover { border-color:var(--accent); }
.order-row .ot { display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; }
.order-row .ono { color:var(--accent); font-weight:700; font-size:14px; }
.order-row .od { color:var(--dim); font-size:12px; margin-top:4px; line-height:1.5; }
.order-row .od .disc { color:var(--yellow); }
.order-row .op { font-size:11px; color:var(--purple); }
/* 订单详情弹窗 */
#detailOverlay { display:none; position:fixed; top:0;left:0;right:0;bottom:0; background:rgba(0,0,0,.55); z-index:6000; justify-content:center; align-items:flex-start; padding:24px 12px; overflow-y:auto; }
#detailOverlay.show { display:flex; }
.detail-card { background:var(--card); border:1px solid var(--border); border-radius:14px; padding:24px; width:520px; max-width:100%; box-shadow:0 16px 48px rgba(0,0,0,.5); }
.detail-card h2 { color:var(--accent); font-size:17px; margin-bottom:14px; }
.detail-card .d-line { display:flex; justify-content:space-between; padding:7px 0; border-bottom:1px solid var(--border); font-size:13px; }
.detail-card .d-line.total { border-bottom:none; font-size:17px; font-weight:700; margin-top:10px; padding-top:12px; border-top:2px solid var(--accent); }
.detail-card .d-total-val { color:var(--green); }
.detail-close { margin-top:16px; text-align:right; }
.tag { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; font-weight:600; }
.tag-admin { background:rgba(188,140,255,.15); color:var(--purple); }
.tag-cashier { background:rgba(88,166,255,.15); color:var(--accent); }
.empty-state { text-align:center; color:var(--dim); padding:40px; font-size:14px; }
/* 滚动条 */
*::-webkit-scrollbar { width:8px; }
*::-webkit-scrollbar-track { background:transparent; }
*::-webkit-scrollbar-thumb { background:var(--border); border-radius:4px; }
@media(max-width:600px){ .detail-card { width:100%; padding:16px; } .container{margin:10px auto;padding:0 10px;} }
</style>
</head>
<body>
<div class="header">
<div style="display:flex;align-items:center;gap:12px">
<h1>📜 订单记录</h1>
<a href="cashier.php" class="btn btn-ghost" style="text-decoration:none;font-size:12px">← 返回收银台</a>
</div>
<div style="display:flex;align-items:center;gap:10px">
<?php if ($loggedIn): ?>
<span class="tag <?= $role==='admin'?'tag-admin':'tag-cashier' ?>"><?= $role==='admin'?'管理员':'收银员' ?> · <?= esc($name) ?></span>
<a href="cashier.php?logout=1" class="btn btn-red" style="text-decoration:none;font-size:12px">退出</a>
<?php else: ?>
<a href="cashier.php" class="btn btn-blue" style="text-decoration:none;font-size:12px">登录收银台</a>
<?php endif; ?>
</div>
</div>
<div class="container">
<!-- 搜索/筛选 -->
<div class="panel">
<h3>🔍 筛选与搜索</h3>
<div class="search-bar">
<input type="text" id="searchPlayer" placeholder="玩家名..." oninput="loadOrders()">
<input type="text" id="searchOrderNo" placeholder="订单号..." oninput="loadOrders()">
<select id="filterPayMode" onchange="loadOrders()">
<option value="">全部收款方式</option>
<option value="bond">债券扣款</option>
<option value="cash">现金记账</option>
</select>
<select id="limit" onchange="loadOrders()">
<option value="30">最近 30 条</option>
<option value="50" selected>最近 50 条</option>
<option value="100">最近 100 条</option>
<option value="200">最近 200 条</option>
</select>
<button class="btn btn-blue" onclick="loadOrders()">刷新</button>
</div>
</div>
<!-- 订单列表 -->
<div class="panel">
<h3>📋 订单列表 <span id="orderCount" style="font-weight:normal;color:var(--dim);font-size:12px"></span></h3>
<div id="orderList"><div class="empty-state">加载中…</div></div>
</div>
</div>
<!-- 订单详情弹窗 -->
<div id="detailOverlay">
<div class="detail-card" id="detailBody"><!-- JS填充 --></div>
</div>
<script>
const STATE = <?php echo $initialState; ?>;
function esc(s){ s = String(s == null ? '' : s); var m = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}; return s.replace(/[&<>"]/g, function(c){ return m[c]; }); }
async function api(url, opts, timeoutMs = 20000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const r = await fetch(url, Object.assign(
{headers: {'X-Requested-With': 'orders'}, credentials: 'same-origin'},
opts || {},
{signal: ctrl.signal}
));
return await r.json();
} finally {
clearTimeout(timer);
}
}
async function loadOrders() {
const el = document.getElementById('orderList');
el.innerHTML = '<div class="empty-state">加载中…</div>';
try {
const params = new URLSearchParams();
params.set('action', 'order_list');
params.set('limit', document.getElementById('limit').value || '50');
const p = document.getElementById('searchPlayer').value.trim();
const o = document.getElementById('searchOrderNo').value.trim();
const pm = document.getElementById('filterPayMode').value;
if (p) params.set('player', p);
if (o) params.set('order_no', o);
if (pm) params.set('pay_mode', pm);
const d = await api('api/cashier.php?' + params.toString());
document.getElementById('orderCount').textContent = '';
if (!d.success) {
el.innerHTML = '<div class="empty-state">加载失败:' + esc(d.message || '未知错误') + ',<a href="javascript:loadOrders()" style="color:var(--accent)">点击重试</a></div>';
return;
}
if (!d.data || !d.data.length) {
el.innerHTML = '<div class="empty-state">暂无符合条件的订单</div>';
return;
}
document.getElementById('orderCount').textContent = '(共 ' + d.data.length + ' 条)';
el.innerHTML = d.data.map(o => {
const items = (o.items_detail && Array.isArray(o.items_detail)) ? o.items_detail.map(i=>esc(i.name)+'x'+i.amount).join('、') : '';
const disc = (parseInt(o.discount_percent)>0) ? ` <span class="disc">(${o.discount_percent}% off -${o.discount_amount})</span>` : '';
const pmBadge = o.payment_method === 'cash' ? '<span style="color:var(--yellow)">[现金]</span>' : '';
return `<div class="order-row" data-order='${escapeHtmlAttr(JSON.stringify(o))}'>
<div class="ot">
<span class="ono">${esc(o.order_no)} ${pmBadge}</span>
<span>${o.operator_type==='admin'?'管理员':'收银员'}:${esc(o.operator_name)}</span>
</div>
<div class="od">玩家 <b>${esc(o.player_name)}</b> · ${items}${disc}</div>
<div class="od">实收 <b style="color:var(--green)">${o.total_price}</b> 债券 · ${new Date((o.created_at||0)*1000).toLocaleString('zh-CN')}</div>
</div>`;
}).join('');
} catch(e) {
const msg = (e && e.name === 'AbortError') ? '加载超时(服务器繁忙),' : '加载失败,';
el.innerHTML = '<div class="empty-state">' + msg + '<a href="javascript:loadOrders()" style="color:var(--accent)">点击重试</a></div>';
}
}
function escapeHtmlAttr(str) {
// 安全转义JSON用于HTML属性(data-order):先转 & 再转引号,
// 避免数据中的 & 被浏览器当成实体解码而破坏 JSON,且对单/双引号均做转义
return String(str)
.replace(/&/g, "&amp;")
.replace(/'/g, "&#39;")
.replace(/"/g, "&quot;");
}
function showDetail(orderData) {
const items = (orderData.items_detail && Array.isArray(orderData.items_detail)) ? orderData.items_detail : [];
const itemsHtml = items.map(it =>
`<div class="d-line"><span>${esc(it.name)} × ${it.amount}</span><span>${it.line_total} 债券</span></div>`
).join('');
const discLine = parseInt(orderData.discount_percent)>0
? `<div class="d-line" style="color:var(--yellow)"><span>手动折扣 ${orderData.discount_percent}%</span><span>-${orderData.discount_amount} 债券</span></div>` : '';
const payModeText = orderData.payment_method === 'cash' ? '现金(仅记账)' : '债券扣款';
const settlementName = orderData.settlement === 'shulker' ? '潜影盒打包' : '塞背包(环保单)';
const card = document.getElementById('detailBody');
card.innerHTML = `
<h2>📋 订单详情</h2>
<div class="d-line"><span>订单号</span><span style="color:var(--accent);font-weight:700">${esc(orderData.order_no)}</span></div>
<div class="d-line"><span>时间</span><span>${new Date((orderData.created_at||0)*1000).toLocaleString('zh-CN')}</span></div>
<div class="d-line"><span>玩家</span><span><b>${esc(orderData.player_name)}</b></span></div>
<div class="d-line"><span>操作员</span><span>${orderData.operator_type==='admin'?'管理员':esc(orderData.operator_name)}</span></div>
<div class="d-line"><span>结算方式</span><span>${settlementName}</span></div>
<div class="d-line"><span>收款方式</span><span>${payModeText}</span></div>
<div class="d-line" style="border-top:1px dashed var(--border);margin:8px 0"></div>
${itemsHtml}
<div class="d-line" style="border-top:1px dashed var(--border);margin:8px 0"><span>商品小计</span><span>${orderData.subtotal || 0} 债券</span></div>
${discLine}
<div class="d-line total"><span>实收合计</span><span class="d-total-val">${orderData.total_price} 债券</span></div>
<div class="detail-close">
<button class="btn btn-ghost" onclick="closeDetail()">关闭</button>
</div>`;
document.getElementById('detailOverlay').classList.add('show');
}
function closeDetail() { document.getElementById('detailOverlay').classList.remove('show'); }
document.getElementById('detailOverlay').addEventListener('click', e => { if(e.target.id === 'detailOverlay') closeDetail(); });
// ★ 订单行点击事件委托(替代 onclick 内联 JSON,避免引号冲突导致 SyntaxError)
document.getElementById('orderList').addEventListener('click', e => {
const row = e.target.closest('.order-row');
if (!row) return;
try { showDetail(JSON.parse(row.dataset.order)); } catch(err) { console.error('订单数据解析失败:', err); }
});
// 启动
if (STATE.logged_in) loadOrders();
else document.getElementById('orderList').innerHTML = '<div class="empty-state">请先<a href="cashier.php" style="color:var(--accent)">登录收银台</a>后查看订单</div>';
// ★ 全局错误捕获:捕获任何未处理的JS错误并显示在页面上(便于排查"全新浏览器仍报错")
window.onerror = function(msg, url, line, col, err) {
var el = document.getElementById('orderList');
if (el) {
var info = 'JS Error: ' + String(msg);
if (url) info += '\n文件: ' + url + (line ? ':' + line : '') + (col ? ':' + col : '');
if (err && err.stack) info += '\n堆栈: ' + err.stack.substring(0, 500);
el.innerHTML = '<div class="empty-state" style="color:var(--red);word-break:break-all;text-align:left;padding:20px;border:1px solid var(--red);border-radius:8px;margin-top:10px">⚠️ 页面脚本错误:\n<pre style="white-space:pre-wrap;color:var(--red)">' + esc(info) + '</pre>\n<a href="javascript:location.reload()" style="color:var(--accent)">[刷新页面]</a></div>';
}
return false;
};
// ★ Promise未捕获 rejection 捕获
window.onunhandledrejection = function(e) {
var el = document.getElementById('orderList');
if (el) {
el.innerHTML = '<div class="empty-state" style="color:var(--yellow)">⚠️ 异步请求失败: ' + esc(String(e.reason || 'unknown')) + ' <a href="javascript:loadOrders()" style="color:var(--accent)">[重试]</a></div>';
}
};
</script>
</body>
</html>
-34
View File
@@ -1,34 +0,0 @@
## 充值订单编号配置模块
1. 本文件属于独立模块<br>
2. 本文件属于配置+文档一体化<br>
---
### 以下是配置区域:
```json
{
"当前配置":"前缀码+当前时间+4位订单验证码+玩家名字",
"前缀码":"CYZJ",
"订单验证码长度":4,
"是否固定订单号格式":false,
"订单号格式":"[00000000]"
}
```
---
### 以下是说明区域:
| 英文变量名 | 渲染内容 | 括弧支持 | 可否不写括号 | 中文变量名 |
| :---: | :---: | :---: | :---: | :---: |
| username | 玩家名字 | 中英小括号、中括号和大括号 | √ | 用户名/玩家名 |
| front | 前缀码 | 中英小括号、中括号&大括号 | √ | 前缀码 |
| timeis | 下单时间 | 中英小括号、中括号&大括号 | √ | 下单时间/当前时间 |
| ordcode | 订单校验码 | 中英小括号、中括号&大括号 | √ | 验证码/下单码 |
## 常见问题
- 变量与变量之间如何连接?答: 使用数字加号或者减号连接。
- 固定订单号格式是什么意思?答: 不按组合格式排列订单号,使用固定模板,"(前缀码)+(订单号)"格式
- 固定格式订单如何防重?答: 自动进位,比如000001,000002。一个订单号对应一条付款记录
- 固定订单格式支持哪些内容?答: 支持纯数字格式,其他都是回退六位兜底
-5600
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
<?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>
-2302
View File
File diff suppressed because it is too large Load Diff
-6
View File
File diff suppressed because one or more lines are too long
-95
View File
@@ -1,95 +0,0 @@
# Sdf1_login Web 端(PHP 后端)
## 开源协议
本项目(Sdf1_login 的 PHP Web 后端,配套 Java 插件一同授权)采用 **GNU Affero General Public License v3.0(AGPL-3.0)**,协议全文见 [LICENSE](LICENSE)。
- 你可以自由使用、修改、分发本项目。
- **本项目本身就是网络服务,若你部署为对外网站/服务,必须向使用者提供完整的对应源代码**(AGPL 第 13 条)。
- 衍生作品必须同样以 AGPL-3.0 授权分发。
- 本项目按「原样」提供,不附带任何担保。
> **部署者须知**:请把根目录的 [`开源地址.txt`](开源地址.txt) 里的地址改成**你自己**的后端源码仓库地址(第一行非 `#` 注释的 http/https 地址即生效)。玩家端页脚会自动读取并展示该地址,用于满足 AGPL 对网络服务提供源码获取途径的要求;出厂默认指向 [`https://git.ypshidifu.cn/youpaishidifu/Sdf1_login/src/branch/php/`](https://git.ypshidifu.cn/youpaishidifu/Sdf1_login/src/branch/php/)。
> 本目录是 Minecraft PaperMC 插件 **`Sdf1_login`**(即本仓库的 `../Sdf1_login`)配套的 PHP Web 后端。
> 两个工程是**一对**:Java 插件负责游戏内逻辑(登录注册、债券经济、商城、领地、工单、PVP…),
> 本目录负责 Web 侧的界面与数据(玩家面板、管理后台、收银台、在线充值、正版登录)。
## 1. 两个工程的关系(一图流)
```
游戏内(Java 插件 Sdf1_login) Web 侧(本目录)
───────────────────────────── ───────────────────────────
/reg /login /cypay /shop /web /mslogin ◄──► login.php / player.php 玩家面板
/sdf1_login 管理面板 ◄──► admin.php 管理后台(19 模块)
商城/CDK/债券流水 权威数据在 Java ◄──► cashier.php 独立收银台
api/pay.php 彩虹易支付在线充值
数据通道:① Java 调 PHP 的 api/sync.php(HTTP,带 secretKey,60+ action 推/拉)
② 共享 SQLite 队列(web.db 里的 pending 表,Java 每 5 秒轮询消费)
③ 领地/用户组等变更走 web_admin_changes 队列双向同步
```
- 对应文档:Java 端 `../Sdf1_login/README.md`(使用手册)
## 2. 目录结构速览
| 路径 | 说明 |
|---|---|
| `config.php` | **全部配置入口**:SECRET_KEY(必须与 Java 端一字不差)、管理员账号、SMTP、游戏库路径、人机验证厂商选择。仓库里是 `REPLACE_ME_*` 占位符,真实值只在服务器 |
| `core.php` | 公共引擎(约 80KB):SQLite 单例(WAL + 锁竞争调优)、建表/迁移、Token 双轨体系、管理员/收银员会话、SMTP、IP 风控黑名单入口拦截。**几乎所有文件都 require 它** |
| `security.php` | 2026-09-29 入侵事件后的三层加固:TOTP 二次验证 + IP 白名单 + 后台访问令牌 + 会话纪元 |
| 页面层 | `login.php` → `player.php`(玩家 SPA,242KB);`admin_login.php` → `admin.php`(管理 SPA,230KB,内嵌 `cashier.js` 收银模块);`cashier.php` 独立收银台;`service.php` 服务商面板;`freeze.php` / `reset_password.php` 邮件自助;`active_players.php` / `online_curve.php` / `today_bonds.php` 统计页(兼 JSON API);`admin_2fa_setup.php` 安全配置引导页 |
| `api/` | 接口层,核心是 **`sync.php`**(Java↔PHP 唯一同步入口,60+ action);其余:`admin.php` 后台 API、`shop.php` 商城、`cdk.php`、`pay.php` + `poller_online.php` + `pay_poller.php` 支付与补单、`minecraft_auth.php` MS 正版 OAuth 代理、`land_api.php` 领地/用户组、`ticket.php` 工单、`security_alert.php` 异地登录回调、`sn.php` 物品 SN 防刷等 |
| `db/` | **`web.db`**(约 40 张表,PHP 读写、Java 经接口读写,绝不直开文件)+ **`orders.db`**(纯 PHP 独占:pay_orders / cashier_orders / shop_configs / mc_auth_sessions / cashiers,拆独立库是为了消除 SQLite 文件锁竞争) |
| 文档 | `pay.md`(订单号格式配置) |
## 3. 数据:三个 SQLite 库的分工
| 库 | 谁读写 | 内容 |
|---|---|---|
| `db/web.db` | PHP + Java(Java 经 `api/sync.php` 等 HTTP 接口,不直开文件) | users、tokens、weblogin_*、shop_items、cdk、web_transactions、bond_cache、online_players、web_area_lands/permissions、web_user_groups、web_tickets、SN 系列、各 pending 队列 |
| `db/orders.db` | **纯 PHP** | 在线充值订单、收银台订单、充值档位配置、MS OAuth 会话、收银员账号 |
| 游戏侧 `login.db` / `bond.db` | **Java 独占** | 插件账号与债券账本。PHP 唯一例外:`api/land_api.php` 验证玩家时可直读 `GAME_LOGIN_DB` 兜底 |
## 4. 与 Java 插件的通信模型(重点)
1. **没有 PHP → Java 的 HTTP 调用**(`config.php` 里的 `CALLBACK_PORT` / `GAME_SERVER_HOST` 是历史死配置)。
方向只有 Java → PHP(`sync.php` 等),以及 PHP 写队列表、Java 轮询消费。
2. **四条共享队列**(都在 web.db):
- `web_transactions`(pending):发货/加债券/CDK/充值到账 → Java `pull_pending_transactions` 拉取执行,`confirmed_transactions` 防重
- `sync_requests`:"立即来拉"标记(购物/改价后触发 Java 快速同步)
- `web_admin_changes`:PHP 侧领地删改/用户组变更 → Java 消费后回推最终状态(**Java 是领地权威源**)
- `web_login_requests` / `web_register_requests` / `cdk_validate_requests` / `pending_player_validations`:密码由 **Java 用 login.db 真实凭证校验**,PHP 禁止自验
3. **玩家 Web 登录链**:游戏内 `/web` → Java 生成 weblogin_token 推给 PHP → 浏览器 `login.php?token=` → player.php;
无 token 时走密码 → 写 pending → Java 校验 → 前端轮询 `check_web_login_result`。
4. **在线充值链**:`pay.php` 建单(orders.db)→ 彩虹易支付网关 → notify 验签幂等 + CF WAF 拦截 notify 时的 `poller_online.php` 直扫平台 MySQL 补单双保险 → 写 pending 流水 → Java 轮询加债券到账。
多服共用一张 `pay_order` 表时靠 `PAY_ORDER_PREFIX` 隔离(生产 `RE`,测试改如 `RT`)。
## 5. 四套鉴权
| 角色 | 方式 |
|---|---|
| 玩家 | 游戏内 `/web` 签发的 weblogin_token;或账密(Java 异步校验);3 分钟同 IP 免密快速重连 |
| 管理员 | PHP session + 密码,叠加 `security.php` 三层加固(TOTP / IP 白名单 / 后台访问令牌),被锁外时用 boot 令牌进 `admin_2fa_setup.php` 逃生 |
| 收银员 | `cashiers` 表 bcrypt + 折扣上限 + 现金收款权限 |
| 服务商 | 游戏账号 + `web_service_providers` 授权表 |
## 6. 首次部署清单
1. **改 `config.php` 占位符**:`SECRET_KEY`(与 Java 端 `插件设置` 保持一致,否则同步接口全 401)、`ADMIN_PASS`、`SMTP_*`(不发邮件可留)、`GAME_*_DB/DIR`、`PAY_ORDER_PREFIX`。
2. **首次访问 `https://域名/plugin/admin_2fa_setup.php`** 保存任意一项 → 自动把缺失的 `SEC_*` / `ADMIN_IP_WHITELIST` / 2FA 密钥随机生成并写回 config.php 的 `>>>SEC-UPDATE` 区(写前自动备份 `db/config_bak/`),并转入强制模式。
3. **Java 侧**:WebManager 的 webBaseUrl 指向本站;启用 web 通信后开始 5 秒轮询队列。
4. 部署走 `deploy_php_only.py`(FTP)——脚本**永远排除** `config.php`、`pay_secrets.php`、`captcha_keys.php`、`lsky_keys.php`、`db/`:这些文件仓库里是空模板/占位符,覆盖一次线上真值 = 同步 401 + 后台锁死 + 支付补单全挂(均有事故复盘注释)。
5. 支付商户密钥(汇付/RSA/平台 MySQL/MS OAuth)在 `api/pay_secrets.php`(仓库为范文,真值手工上传服务器)与 `密钥.md`(**仅本地存储,严禁入库**)。
## 7. 安全注意(已知项)
- `api/diag_db_content.php` **无鉴权**可 dump 两库表结构与样例,正式站应删除或加门禁。
- `api/get_online_players.php` 是损坏文件(依赖不存在的 `../inc/function.php`)。
- `sync.php` 里 `web_login_requests` 存的是明文密码(由 Java 消费校验)——web.db 文件泄露 ≈ 全服账号泄露,`db/` 已 git-ignore。
- 仓库版 `config.php` 严禁出现真实口令/密钥(2026-09-29 用户指令,历史事故复盘见文件头注释)。
---
-14
View File
@@ -1,14 +0,0 @@
<?php
/**
* 充值订单对账 — 已合并到管理后台,此页面自动跳转
* ★ 第三层(2026-09-29):admin.php 必须带入口令牌,否则会 404,这里由服务端补上。
*/
require_once __DIR__ . '/core.php';
if (session_status() === PHP_SESSION_NONE) session_start();
$to = function_exists('secTokenUrl') ? secTokenUrl('admin.php') : 'admin.php';
if (isAdminLoggedIn()) {
header('Location: ' . $to . '#recharge_orders');
exit;
}
header('Location: ' . $to);
exit;
-274
View File
@@ -1,274 +0,0 @@
<?php
/**
* 密码重置页面
* 玩家通过邮件链接访问此页面重置密码
*/
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
require_once __DIR__ . '/core.php';
require_once __DIR__ . '/api/sync.php';
// 获取token参数
$token = isset($_GET['token']) ? $_GET['token'] : '';
if (!$token) {
die('无效的重置链接');
}
// 验证token
$db = getDB();
$stmt = $db->prepare("SELECT * FROM password_reset_tokens WHERE token = :token");
$stmt->bindValue(':token', $token, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if (!$row) {
die('重置链接无效或已过期');
}
if (time() > $row['expire_at']) {
die('重置链接已过期,请重新申请');
}
$player = $row['player_name'];
// 处理表单提交
$message = '';
$error = '';
$success = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$newPassword = isset($_POST['password']) ? $_POST['password'] : '';
$confirmPassword = isset($_POST['confirm_password']) ? $_POST['confirm_password'] : '';
if (!$newPassword || strlen($newPassword) < 6) {
$error = '密码长度至少6位';
} elseif ($newPassword !== $confirmPassword) {
$error = '两次输入的密码不一致';
} else {
// 生成新的密码凭证
$saltBytes = random_bytes(16);
$salt = base64_encode($saltBytes);
$passwordHash = base64_encode(hash('sha256', $saltBytes . $newPassword, true));
// 更新密码凭证
storeWebLoginCredentials($player, $passwordHash, $salt);
// ★ 解冻:清除 frozen 标记并通知 Java 解除 Bukkit 原生封禁
$db->exec("UPDATE users SET frozen = 0, freeze_token = '' WHERE player_name = " . SQLite3::escapeString($player));
$now = time();
$unf = $db->prepare("INSERT INTO web_admin_changes (change_type, target_id, target_name, change_data, created_at) VALUES ('unfreeze', 0, :name, '{}', :now)");
$unf->bindValue(':name', $player, SQLITE3_TEXT);
$unf->bindValue(':now', $now, SQLITE3_INTEGER);
$unf->execute();
// 删除重置token
$stmt = $db->prepare("DELETE FROM password_reset_tokens WHERE token = :token");
$stmt->bindValue(':token', $token, SQLITE3_TEXT);
$stmt->execute();
$success = true;
$message = '密码重置成功!账号已解冻,请在游戏中使用新密码登录';
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>重置密码 - Sdf1 Minecraft</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
max-width: 420px;
width: 100%;
padding: 40px;
}
h1 {
color: #333;
font-size: 28px;
text-align: center;
margin-bottom: 8px;
}
.subtitle {
color: #666;
font-size: 14px;
text-align: center;
margin-bottom: 32px;
}
.player-info {
background: #f5f5f5;
border-radius: 8px;
padding: 16px;
text-align: center;
margin-bottom: 24px;
}
.player-info strong {
color: #667eea;
font-size: 18px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
color: #333;
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
}
input[type="password"] {
width: 100%;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
input[type="password"]:focus {
outline: none;
border-color: #667eea;
}
.btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.btn:active {
transform: translateY(0);
}
.alert {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 14px;
}
.alert-error {
background: #fee;
color: #c33;
border: 1px solid #fcc;
}
.alert-success {
background: #efe;
color: #363;
border: 1px solid #cfc;
}
.success-container {
text-align: center;
}
.success-icon {
font-size: 64px;
margin-bottom: 20px;
}
.success-text {
color: #363;
font-size: 18px;
margin-bottom: 24px;
}
.info-text {
color: #666;
font-size: 13px;
line-height: 1.6;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #e0e0e0;
}
</style>
</head>
<body>
<div class="container">
<?php if ($success): ?>
<div class="success-container">
<div class="success-icon">✅</div>
<h1>重置成功</h1>
<p class="success-text"><?php echo htmlspecialchars($message); ?></p>
<div class="info-text">
<strong>提示:</strong><br>
请在Minecraft游戏中使用新密码重新登录Web端
</div>
</div>
<?php else: ?>
<h1>重置密码</h1>
<p class="subtitle">为您的账号设置新密码</p>
<div class="player-info">
<div style="color: #666; font-size: 13px; margin-bottom: 4px;">玩家</div>
<strong><?php echo htmlspecialchars($player); ?></strong>
</div>
<?php if ($error): ?>
<div class="alert alert-error">
<?php echo htmlspecialchars($error); ?>
</div>
<?php endif; ?>
<form method="POST" action="">
<div class="form-group">
<label for="password">新密码</label>
<input type="password" id="password" name="password" required minlength="6" placeholder="至少6位字符">
</div>
<div class="form-group">
<label for="confirm_password">确认密码</label>
<input type="password" id="confirm_password" name="confirm_password" required minlength="6" placeholder="再次输入密码">
</div>
<button type="submit" class="btn">重置密码</button>
</form>
<div class="info-text">
<strong>注意:</strong><br>
• 密码重置后,请使用新密码登录Web端<br>
• 此链接30分钟内有效,使用后自动失效<br>
• 如果这不是您的操作,请忽略此邮件
</div>
<?php endif; ?>
</div>
<script>
// 密码确认
const form = document.querySelector('form');
if (form) {
form.addEventListener('submit', function(e) {
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirm_password').value;
if (password !== confirmPassword) {
e.preventDefault();
alert('两次输入的密码不一致');
}
});
}
</script>
</body>
</html>
-50
View File
@@ -1,50 +0,0 @@
[
{
"备注": {
"1": "此文件严禁铭文以任何形式存入任何git仓库",
"2": "此文件若要入库,必须清空最底下的CF和VA两个参数值",
"3": "做软依赖,选中的供应商如果是空值就回退默认原生/关闭",
"4": "不要硬编码行数,此文件既能作为说明文档,又能作为配置文件。也就是说寻找默认值的时候,值在哪,哪就是配置行。其它的都不管"
}
},
{
"默认值": 1,
"解释说明": {
"1": "参数不区分大小写",
"2": "允许填入全名cloudflare、Turnstile或vaptcha",
"3": "CF、cloudflare、Turnstile=Cf,va、vaptcha=VA",
"4": "默认值允许数字、或字母。0、-1或空白值表示使用默认原生的验证码接口和系统。数字1表示使用cf的验证码,数字2表示使用vaptcha的验证码"
}
},
{
"申请密钥步骤": {
"cf": {
"第一步": "登录CF控制台",
"第二步": "选择应用程序和安全",
"第三步": "选择Turnstile并手动创建小组件",
"第四步": "手动创建小组件,选择一个域名即可",
"第五步": "拿到站点密钥和普通密钥填入即可"
},
"va": {
"第一步": "登录vaptcha官网",
"第二步": "选择邮箱登录",
"第三步": "创建一个验证单元,填入域名",
"第四步": "拿到VID和KEY填入本文件"
}
}
},
{
"名字": "CF",
"站点密钥": "填你的密钥",
"密钥": "这里填你的密钥",
"域名": "这里填你的域名",
"备注": "域名托管在CF,默认开启泛域名"
},
{
"名字": "vaptcha",
"VID": "填你的密钥",
"VKey": "填你的密钥",
"域名": "这里填你的域名",
"备注": "*.ypshidifu.cn=普通单域名,ypshidifu.cn=泛域名"
}
]
-1523
View File
File diff suppressed because it is too large Load Diff
-519
View File
@@ -1,519 +0,0 @@
<?php
require_once __DIR__ . '/security.php'; // 第四层:全局限流(2026-09-30)
header('Cache-Control: no-cache, no-store, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
$BUILD_VERSION = 'v' . filemtime(__FILE__);
$currentVersion = isset($_GET['_v']) ? $_GET['_v'] : '';
if ($currentVersion !== $BUILD_VERSION) {
$params = $_GET;
unset($params['_v']);
$queryString = http_build_query($params);
$path = strtok($_SERVER['REQUEST_URI'], '?');
$newUrl = $path . ($queryString ? '?' . $queryString . '&' : '?') . '_v=' . $BUILD_VERSION;
header('Location: ' . $newUrl, true, 302);
exit;
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>服务商面板 - 草原探险</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,#1a2e1a 0%,#0d1117 100%); border-bottom:1px solid var(--border); padding:14px 24px; display:flex; justify-content:space-between; align-items:center; }
.header h1 { font-size:18px; color:var(--green); }
.container { max-width:900px; margin:20px auto; padding:0 16px; }
.card { background:var(--card); border:1px solid var(--border); border-radius:8px; padding:20px; margin-bottom:16px; }
.card h2 { font-size:16px; margin-bottom:12px; color:var(--green); }
.btn { padding:8px 16px; border:none; border-radius:6px; cursor:pointer; font-size:13px; font-weight:600; transition:all 0.2s; }
.btn-primary { background:var(--accent); color:#fff; }
.btn-primary:hover { background:#79c0ff; }
.btn-green { background:var(--green); color:#fff; }
.btn-dim { background:var(--card); color:var(--dim); border:1px solid var(--border); }
.btn-dim:hover { color:var(--text); border-color:var(--accent); }
input, textarea, select { width:100%; padding:10px; border:1px solid var(--border); border-radius:6px; background:var(--bg); color:var(--text); font-size:14px; }
textarea { resize:vertical; font-family:monospace; }
.tabs { display:flex; gap:6px; margin-bottom:16px; flex-wrap:wrap; }
.tab { padding:6px 14px; background:var(--bg); border:1px solid var(--border); border-radius:4px; cursor:pointer; font-size:12px; color:var(--dim); }
.tab.active { background:var(--accent); color:#fff; border-color:var(--accent); }
.ticket-item { background:rgba(63,185,80,0.05); border:1px solid var(--border); border-radius:8px; padding:14px 16px; margin-bottom:8px; cursor:pointer; transition:all 0.2s; }
.ticket-item:hover { border-color:var(--green); }
.status-dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; }
.md-content { font-size:14px; line-height:1.6; }
.md-content pre { background:rgba(63,185,80,0.1); padding:10px; border-radius:4px; overflow-x:auto; font-size:12px; }
.md-content code { background:rgba(63,185,80,0.15); padding:1px 4px; border-radius:2px; font-size:12px; }
.reply-box { background:rgba(63,185,80,0.05); border-radius:6px; padding:10px 14px; margin-bottom:8px; }
.login-box { max-width:400px; margin:100px auto; }
.login-box h2 { text-align:center; margin-bottom:20px; color:var(--green); }
.error { color:var(--red); font-size:13px; margin-top:8px; }
</style>
</head>
<body>
<div class="header">
<h1>🧑‍💻 服务商面板</h1>
<div style="display:flex;gap:8px;align-items:center">
<span id="providerInfo" style="color:var(--dim);font-size:13px"></span>
<button class="btn btn-dim" onclick="doLogout()" style="font-size:12px;padding:4px 12px">退出</button>
</div>
</div>
<div class="container" id="mainContainer">
<!-- 登录/面板 动态渲染 -->
</div>
<script>
const API = 'api/';
let TOKEN = localStorage.getItem('sdf1_provider_token') || '';
let PLAYER = localStorage.getItem('sdf1_provider_player') || '';
let PROVIDER_ROLE = '';
(async function() {
if (TOKEN && PLAYER) {
await checkProviderAuth();
} else {
renderLogin();
}
})();
function renderLogin() {
document.getElementById('mainContainer').innerHTML = `
<div class="card login-box">
<h2>🧑‍💻 服务商登录</h2>
<p style="color:var(--dim);text-align:center;font-size:13px;margin-bottom:16px">使用游戏账号密码登录(仅限已授权的服务商)</p>
<div style="margin-bottom:12px">
<label style="color:var(--dim);font-size:12px;display:block;margin-bottom:4px">玩家名</label>
<input id="spUser" placeholder="游戏内玩家名" value="${escH(PLAYER)}">
</div>
<div style="margin-bottom:12px">
<label style="color:var(--dim);font-size:12px;display:block;margin-bottom:4px">密码</label>
<input id="spPass" type="password" placeholder="游戏登录密码" onkeydown="if(event.key==='Enter')doProviderLogin()">
</div>
<button class="btn btn-primary" style="width:100%;padding:12px" onclick="doProviderLogin()">登录</button>
<p id="spError" class="error"></p>
</div>`;
}
async function doProviderLogin() {
const user = document.getElementById('spUser').value.trim();
const pass = document.getElementById('spPass').value;
const errEl = document.getElementById('spError');
if (!user || !pass) { errEl.textContent = '请输入用户名和密码'; return; }
try {
// 0. 先清理可能残留的旧请求
const cancelUrl = new URL(API + 'sync.php', location.href);
cancelUrl.searchParams.set('action', 'cancel_web_login');
cancelUrl.searchParams.set('player', user);
await fetch(cancelUrl);
// 1. 先创建weblogin token
const regUrl = new URL(API + 'sync.php', location.href);
regUrl.searchParams.set('action', 'web_login_request');
const regRes = await fetch(regUrl, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({player: user, password: pass})
});
const regData = await regRes.json();
if (!regData.success) { errEl.textContent = regData.message || '登录请求失败'; return; }
// 2. 等待Java端处理(轮询)
const requestId = regData.data && regData.data.request_id;
if (!requestId) { errEl.textContent = '未获取请求ID'; return; }
let verified = false;
for (let i = 0; i < 30; i++) {
await new Promise(r => setTimeout(r, 1000));
const checkUrl = new URL(API + 'sync.php', location.href);
checkUrl.searchParams.set('action', 'check_web_login_result');
checkUrl.searchParams.set('request_id', requestId);
checkUrl.searchParams.set('player', user);
const checkRes = await fetch(checkUrl);
const checkData = await checkRes.json();
if (checkData.success && checkData.data && checkData.data.status === 'success') {
TOKEN = checkData.data.token || checkData.data.web_token || '';
verified = true;
break;
}
if (checkData.data && checkData.data.status === 'failed') {
// 密码错误,清理pending请求
const cancelUrl3 = new URL(API + 'sync.php', location.href);
cancelUrl3.searchParams.set('action', 'cancel_web_login');
cancelUrl3.searchParams.set('player', user);
await fetch(cancelUrl3);
errEl.textContent = checkData.data.message || '密码错误';
return;
}
}
if (!verified) {
// 超时,清理pending请求
const cancelUrl2 = new URL(API + 'sync.php', location.href);
cancelUrl2.searchParams.set('action', 'cancel_web_login');
cancelUrl2.searchParams.set('player', user);
await fetch(cancelUrl2);
errEl.textContent = '验证超时,请确认游戏服务器在线';
return;
}
// 3. 验证服务商身份
PLAYER = user;
localStorage.setItem('sdf1_provider_token', TOKEN);
localStorage.setItem('sdf1_provider_player', PLAYER);
await checkProviderAuth();
} catch (e) {
errEl.textContent = '登录失败: ' + e.message;
}
}
async function checkProviderAuth() {
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'provider_check');
url.searchParams.set('token', TOKEN);
const res = await fetch(url);
const data = await res.json();
if (data.success && data.data.is_provider) {
PROVIDER_ROLE = data.data.role || 'waiter';
document.getElementById('providerInfo').textContent = `${PLAYER} (${PROVIDER_ROLE})`;
renderPanel();
} else {
TOKEN = '';
localStorage.removeItem('sdf1_provider_token');
renderLogin();
setTimeout(() => {
const errEl = document.getElementById('spError');
if (errEl) errEl.textContent = '你不是授权的服务商,请联系管理员';
}, 100);
}
} catch (e) {
TOKEN = '';
localStorage.removeItem('sdf1_provider_token');
renderLogin();
}
}
let spView = 'available';
function renderPanel() {
const c = document.getElementById('mainContainer');
c.innerHTML = `
<div class="tabs">
<div class="tab ${spView==='available'?'active':''}" onclick="spView='available';renderPanel()">📋 抢单大厅</div>
<div class="tab ${spView==='my'?'active':''}" onclick="spView='my';renderPanel()">👤 我的工单</div>
</div>
<div id="spContent"><p style="color:var(--dim)">加载中...</p></div>`;
if (spView === 'available') loadAvailableTickets();
else loadMyProviderTickets();
}
async function loadAvailableTickets() {
const el = document.getElementById('spContent');
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'available');
url.searchParams.set('token', TOKEN);
const res = await fetch(url);
const data = await res.json();
if (!data.success) { el.innerHTML = `<p style="color:var(--red)">${data.message}</p>`; return; }
const list = data.data.list;
const typeMap = {bug:'🐛 Bug反馈',help:'❓ 求助',report:'📢 举报',apply:'📝 申请',other:'📎 其他'};
if (list.length === 0) {
el.innerHTML = `<div class="card"><p style="color:var(--dim);text-align:center;padding:30px">暂无待接工单</p></div>`;
return;
}
let html = '';
for (const t of list) {
const date = new Date(t.created_at * 1000).toLocaleString('zh-CN');
html += `<div class="ticket-item" onclick="viewSPTicket(${t.id},'available')">
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
<span style="font-weight:600;font-size:14px">#${t.id} ${escH(t.title)}</span>
<span style="font-size:12px;color:var(--yellow)">⏳ 待接</span>
</div>
<div style="font-size:12px;color:var(--dim)">${typeMap[t.type]||t.type} | 👤 ${escH(t.requester)} | 📅 ${date}</div>
</div>`;
}
el.innerHTML = html;
} catch (e) { el.innerHTML = `<p style="color:var(--red)">加载失败: ${e.message}</p>`; }
}
async function loadMyProviderTickets() {
const el = document.getElementById('spContent');
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'provider_list');
url.searchParams.set('token', TOKEN);
url.searchParams.set('type', 'processing');
const res = await fetch(url);
const data = await res.json();
if (!data.success) { el.innerHTML = `<p style="color:var(--red)">${data.message}</p>`; return; }
const list = data.data.list;
const typeMap = {bug:'🐛 Bug反馈',help:'❓ 求助',report:'📢 举报',apply:'📝 申请',other:'📎 其他'};
const statusMap = {submitted:{text:'⏳ 已提交',color:'var(--yellow)'},replied:{text:'💬 已回复',color:'var(--accent)'},completed:{text:'✅ 已完结',color:'var(--green)'},withdrawn:{text:'🔙 已撤销',color:'var(--dim)'},rejected:{text:'🚫 已驳回',color:'var(--red)'}};
if (list.length === 0) {
el.innerHTML = `<div class="card"><p style="color:var(--dim);text-align:center;padding:30px">暂无处理中的工单</p></div>`;
return;
}
let html = '';
for (const t of list) {
const s = statusMap[t.status] || {text: t.status, color: 'var(--dim)'};
const date = new Date(t.created_at * 1000).toLocaleString('zh-CN');
html += `<div class="ticket-item" onclick="viewSPTicket(${t.id},'my')">
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
<span style="font-weight:600;font-size:14px">#${t.id} ${escH(t.title)}</span>
<span style="font-size:12px;color:${s.color}">${s.text}</span>
</div>
<div style="font-size:12px;color:var(--dim)">${typeMap[t.type]||t.type} | 👤 ${escH(t.requester)} | 📅 ${date}</div>
</div>`;
}
el.innerHTML = html;
} catch (e) { el.innerHTML = `<p style="color:var(--red)">加载失败: ${e.message}</p>`; }
}
// 详情渲染:[图片N] 占位符 -> markdown 图片;未被引用的图片补在末尾
function applyTicketImgs(desc, images) {
let out = String(desc || '');
if (!images || !images.length) return out;
const used = new Array(images.length).fill(false);
out = out.replace(/\[图片(\d+)\]/g, function(m, n) {
const i = parseInt(n, 10) - 1;
if (i >= 0 && i < images.length && !used[i]) { used[i] = true; return '![](' + images[i] + ')'; }
return m;
});
const left = images.filter(function(u, k) { return !used[k]; });
if (left.length) out += (out ? '\n\n' : '') + left.map(function(u) { return '![](' + u + ')'; }).join('\n\n');
return out;
}
async function viewSPTicket(id, from) {
const el = document.getElementById('spContent');
el.innerHTML = '<p style="color:var(--dim)">加载中...</p>';
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'detail');
url.searchParams.set('token', TOKEN);
url.searchParams.set('id', id);
const res = await fetch(url);
const data = await res.json();
if (!data.success) { el.innerHTML = `<p style="color:var(--red)">${data.message}</p>`; return; }
const t = data.data;
const typeMap = {bug:'🐛 Bug反馈',help:'❓ 求助',report:'📢 举报',apply:'📝 申请',other:'📎 其他'};
const statusMap = {submitted:{text:'⏳ 已提交',color:'var(--yellow)'},replied:{text:'💬 已回复',color:'var(--accent)'},completed:{text:'✅ 已完结',color:'var(--green)'},withdrawn:{text:'🔙 已撤销',color:'var(--dim)'},rejected:{text:'🚫 已驳回',color:'var(--red)'}};
const s = statusMap[t.status] || {text: t.status, color: 'var(--dim)'};
const date = new Date(t.created_at * 1000).toLocaleString('zh-CN');
const isMyTicket = t.assigned_to === PLAYER;
const canReply = !['withdrawn','rejected','completed'].includes(t.status) && isMyTicket;
const canGrab = t.status === 'submitted' && !t.assigned_to;
let html = `<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h2 style="margin:0">#${t.id} ${escH(t.title)}</h2>
<span style="color:${s.color};font-size:13px">${s.text}</span>
</div>
<div style="font-size:12px;color:var(--dim);margin-bottom:12px">
${typeMap[t.type]||t.type} | 提交者: ${escH(t.requester)} | 处理人: ${t.assigned_to ? escH(t.assigned_to) : '未分配'} | ${date}
</div>
<div style="background:rgba(63,185,80,0.05);border:1px solid var(--border);border-radius:6px;padding:12px;margin-bottom:12px">
<div style="color:var(--dim);font-size:11px;margin-bottom:4px">描述</div>
<div class="md-content">${spRenderMd(applyTicketImgs(t.description || '无描述', t.images))}</div>
</div>`;
if (t.reject_reason) {
html += `<div style="background:rgba(248,81,73,0.1);border:1px solid var(--red);border-radius:6px;padding:12px;margin-bottom:12px">
<div style="color:var(--red);font-size:11px;margin-bottom:4px">🚫 驳回原因</div>
<div class="md-content">${spRenderMd(t.reject_reason)}</div></div>`;
}
// 回复列表
html += `<div style="margin-bottom:12px"><div style="font-size:11px;color:var(--dim);margin-bottom:6px">💬 回复 (${t.replies ? t.replies.length : 0})</div>`;
if (t.replies && t.replies.length > 0) {
for (const r of t.replies) {
const rDate = new Date(r.created_at * 1000).toLocaleString('zh-CN');
const roleColors = {user:'var(--accent)',admin:'var(--red)',provider:'var(--green)'};
const roleNames = {user:'玩家',admin:'管理员',provider:'服务商'};
const bgColor = r.role === 'admin' ? 'rgba(248,81,73,0.08)' : r.role === 'provider' ? 'rgba(63,185,80,0.08)' : 'rgba(88,166,255,0.08)';
html += `<div class="reply-box" style="background:${bgColor}">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:4px">
<span><b style="color:${roleColors[r.role]||'var(--text)'}">${escH(r.sender)}</b> <span style="color:var(--dim)">(${roleNames[r.role]||r.role})</span></span>
<span style="color:var(--dim)">${rDate}</span>
</div>
<div class="md-content">${spRenderMd(r.message)}</div>
</div>`;
}
} else {
html += `<p style="color:var(--dim);font-size:12px">暂无回复</p>`;
}
html += `</div>`;
// 操作
html += `<div style="display:flex;gap:8px;flex-wrap:wrap">`;
if (canGrab) {
html += `<button class="btn btn-green" onclick="grabSPTicket(${t.id})">📩 接单</button>`;
}
if (canReply) {
html += `<div style="width:100%;margin-top:8px">
<textarea id="spReplyMsg" rows="2" placeholder="输入回复内容(支持Markdown)" style="font-family:monospace"></textarea>
<div style="margin-top:6px;display:flex;gap:8px">
<button class="btn btn-primary" onclick="spReply(${t.id})">💬 回复</button>
<button class="btn btn-green" onclick="spComplete(${t.id})">✅ 标记完结</button>
</div>
</div>`;
}
html += `<button class="btn btn-dim" onclick="spView='${from}';renderPanel()" style="margin-top:8px">← 返回</button></div>`;
html += `</div>`;
el.innerHTML = html;
} catch (e) { el.innerHTML = `<p style="color:var(--red)">加载失败: ${e.message}</p>`; }
}
async function grabSPTicket(id) {
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'grab');
url.searchParams.set('token', TOKEN);
url.searchParams.set('id', id);
const res = await fetch(url);
const data = await res.json();
if (data.success) { viewSPTicket(id, 'available'); } else { alert(data.message); }
} catch (e) { alert('接单失败: ' + e.message); }
}
async function spReply(id) {
const msg = document.getElementById('spReplyMsg').value.trim();
if (!msg) { alert('请输入回复内容'); return; }
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'provider_reply');
url.searchParams.set('token', TOKEN);
url.searchParams.set('id', id);
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({message: msg})});
const data = await res.json();
if (data.success) { viewSPTicket(id, 'my'); } else { alert(data.message); }
} catch (e) { alert('回复失败: ' + e.message); }
}
async function spComplete(id) {
if (!confirm('确定标记此工单已完结?')) return;
try {
const url = new URL(API + 'ticket.php', location.href);
url.searchParams.set('action', 'complete');
url.searchParams.set('token', TOKEN);
url.searchParams.set('id', id);
const res = await fetch(url);
const data = await res.json();
if (data.success) { viewSPTicket(id, 'my'); } else { alert(data.message); }
} catch (e) { alert('操作失败: ' + e.message); }
}
function doLogout() {
TOKEN = '';
PLAYER = '';
localStorage.removeItem('sdf1_provider_token');
localStorage.removeItem('sdf1_provider_player');
renderLogin();
}
function escH(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function spRenderMd(text) {
if (!text) return '';
text = text.replace(/\r/g, '');
try {
let s = escH(text);
// 代码块(提取保护,防止<br>污染)
let cbs = [];
s = s.replace(/```(\w*)\n?([\s\S]*?)```/g, function(m, lang, code) {
let idx = cbs.length;
cbs.push('<pre style="background:rgba(63,185,80,0.1);padding:10px;border-radius:4px;overflow-x:auto;font-size:12px;white-space:pre-wrap">' + code.replace(/\n$/, '') + '</pre>');
return '\x00CB' + idx + '\x00';
});
// 行内代码
s = s.replace(/`([^`]+)`/g, '<code style="background:rgba(63,185,80,0.15);padding:1px 4px;border-radius:2px;font-size:12px">$1</code>');
// 删除线 ~~text~~
s = s.replace(/~~(.+?)~~/g, '<del>$1</del>');
// 粗体+斜体
s = s.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
s = s.replace(/\*(.+?)\*/g, '<i>$1</i>');
// 图片 ![alt](url)
s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:6px;margin:6px 0">');
// 链接(排除图片)
s = s.replace(/(?<!!)\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:var(--accent)">$1</a>');
// 标题(从多到少匹配,严格匹配空格分隔)
s = s.replace(/^######\s+(.+)$/gm, '<h6 style="margin:3px 0;font-size:11px">$1</h6>');
s = s.replace(/^#####\s+(.+)$/gm, '<h5 style="margin:4px 0;font-size:12px">$1</h5>');
s = s.replace(/^####\s+(.+)$/gm, '<h5 style="margin:4px 0 2px;font-size:12px">$1</h5>');
s = s.replace(/^###\s+(.+)$/gm, '<h4 style="margin:6px 0 2px;font-size:13px">$1</h4>');
s = s.replace(/^##\s+(.+)$/gm, '<h3 style="margin:8px 0 4px;font-size:14px">$1</h3>');
s = s.replace(/^#\s+(.+)$/gm, '<h2 style="margin:10px 0 6px;font-size:15px">$1</h2>');
// 引用块(支持嵌套)
s = s.replace(/^(?:&gt;[ \t]*)+(.*)$/gm, function(m, content) {
let nestLevel = (m.match(/&gt;/g) || []).length;
let leftPad = Math.min(nestLevel * 6, 24);
let text = content || '&nbsp;';
return '<div style="border-left:3px solid var(--accent);padding:2px 8px;margin:4px 0 4px ' + leftPad + 'px;color:#aaa;font-style:italic;background:rgba(255,255,255,0.03)">' + text + '</div>';
});
// 表格处理
let tableLines = [];
let inTable = false;
let tableHtml = '';
let lines = s.split('\n');
let result = [];
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
if (line.match(/^\|(.+)\|$/)) {
// 分隔行 |---|---|
if (line.match(/^\|[\s\-:|]+\|$/)) {
inTable = true;
continue;
}
if (!inTable && i + 1 < lines.length && lines[i+1].trim().match(/^\|[\s\-:|]+\|$/)) {
// 下一行是分隔行,这是表头
inTable = true;
let cells = line.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
tableHtml = '<table style="border-collapse:collapse;width:100%;margin:6px 0;font-size:12px"><tr style="background:rgba(63,185,80,0.15)">';
for (let c of cells) tableHtml += '<th style="border:1px solid var(--border);padding:4px 8px;text-align:left">' + c + '</th>';
tableHtml += '</tr>';
continue;
}
if (inTable) {
let cells = line.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
tableHtml += '<tr>';
for (let c of cells) tableHtml += '<td style="border:1px solid var(--border);padding:4px 8px">' + c + '</td>';
tableHtml += '</tr>';
continue;
}
}
if (inTable && tableHtml) {
tableHtml += '</table>';
result.push(tableHtml);
tableHtml = '';
inTable = false;
}
result.push(lines[i]);
}
if (tableHtml) {
tableHtml += '</table>';
result.push(tableHtml);
}
s = result.join('\n');
// 无序列表
s = s.replace(/^- (.+)$/gm, '<li style="margin-left:16px;font-size:12px">$1</li>');
// 分割线
s = s.replace(/^---$/gm, '<hr style="border:none;border-top:1px solid var(--border);margin:8px 0">');
// 换行
s = s.replace(/\n/g, '<br>');
// 还原代码块占位符
for (let i = 0; i < cbs.length; i++) {
s = s.replace('\x00CB' + i + '\x00', cbs[i]);
}
return s;
} catch(e) { console.error('[MD_RENDER] Error:', e); return escH(text).replace(/\n/g, '<br>'); }
}
</script>
</body>
</html>
-47
View File
@@ -1,47 +0,0 @@
```json
[
{"text":"配置区开始"},
{
"运行模式": true,
"数据库存储位置": ""
},
{
"归属": "",
"数据库地址": "localhost",
"端口": "3306",
"数据库名": "",
"数据库账号": "",
"数据库密码": ""
},
{
"归属": "",
"数据库地址": "localhost",
"端口": "3306",
"数据库名": "",
"数据库账号": "",
"数据库密码": ""
},
{"text":"配置区域结束"}
]
```
| 配置值 | 意义 | 示例 |
| :---: | :---: | :---: |
| 运行模式 | 选择db/mysql运行。不区分大小写,`true和db`表示使用db,`false和mysql`表示mysql | true |
| 数据库位置 | db模式专用,用来配置存放db的位置。`/xxx`表示绝对值,从根目录开始`./`表示相对值,从当前目录开始。`../`表示相对目录,从上层目录开始 | ./db/ |
| 数据库地址 | mysql专用,表示数据库连接地址。一般情况下填127.0.0.1或localhost | localhost |
| 端口 | MySQL专用,表示连接数据库的端口,默认情况下用3306即可 | 3306
| 数据库名 | mysql专用,表示连接mysql数据库的库名 | sqlname |
| 数据库账号 | mysql专用,表示连接mysql的账号信息 | mysqluser |
| 数据库密码 | mysql专用,表示连接mysql的密码信息,需要和账号是一对 | mysqlpassword |
## 常见问题
问:db不可用<br>
答: 检查php扩展-sqllite是否安装<br>
问: mysql连不上<br>
答: 检查mysql服务是否启动、账密端口是否正确<br>
问: 解析配置失败<br>
答: 把整个json配置区丢入json校验器校验一次json格式
+197
View File
@@ -0,0 +1,197 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitRunnable;
import java.util.*;
public class AFKManager {
// 玩家最后活动时间(空闲才开始计时)
private final Map<UUID, Long> lastAction =
new HashMap<>();
// 每个玩家当前的警告阶段
private final Map<UUID, Integer> lastStage =
new HashMap<>();
// 是否已进入挂机检测(false=还在活动,不计时)
private final Map<UUID, Boolean> afkTracking =
new HashMap<>();
private final ConfigManager config;
private final JavaPlugin plugin;
public AFKManager(ConfigManager config,
JavaPlugin plugin) {
this.config = config;
this.plugin = plugin;
}
public void startCheck() {
new BukkitRunnable() {
public void run() {
tick();
}
}.runTaskTimer(plugin, 20L, 20L);
}
private void tick() {
if (!config.afkEnabled) return;
long now = System.currentTimeMillis();
for (Player p : Bukkit.getOnlinePlayers()) {
String name = p.getName();
UUID uuid = p.getUniqueId();
if (!isLoggedIn(name)) continue;
if (config.getAfkWhitelist().contains(name))
continue;
// 确保有记录
if (!lastAction.containsKey(uuid)) {
lastAction.put(uuid, now);
afkTracking.put(uuid, false);
lastStage.put(uuid, 0);
continue;
}
long last = lastAction.get(uuid);
long idle = (now - last) / 1000;
int timeout = config.afkTimeout;
// 还在活动范围内:不进入挂机检测
if (idle < 60) {
afkTracking.put(uuid, false);
lastStage.put(uuid, 0);
continue;
}
// 超过60秒无活动:开始挂机检测
afkTracking.put(uuid, true);
long remain = timeout - idle;
// 已超时:踢出
if (remain <= 0) {
p.kickPlayer("§c§l你已挂机 "
+ fmtSec(idle) + ",被踢出");
lastAction.remove(uuid);
lastStage.remove(uuid);
afkTracking.remove(uuid);
continue;
}
int stage = lastStage.getOrDefault(uuid, 0);
// 阶段3:最后10秒
if (remain <= 10) {
if (stage < 3) {
lastStage.put(uuid, 3);
}
p.sendTitle(
"§e§l挂机了 " + fmtSec(idle),
"§c还剩 " + remain + " 秒踢出",
0, 22, 3);
playSound(p);
continue;
}
// 阶段2:1分钟~10秒
if (remain <= 60) {
if (stage < 2) {
lastStage.put(uuid, 2);
p.sendMessage(
"§e§l⚠ 挂机警告 ⚠ §f你已挂机 §c"
+ fmtSec(idle)
+ "§f,还剩 §c"
+ fmtSec(remain)
+ "§f踢出");
}
p.sendTitle(
"§e§l挂机了 " + fmtSec(idle),
"§c还剩 " + fmtSec(remain) + " 踢出",
5, 25, 5);
playSound(p);
continue;
}
// 阶段1:3分钟提醒
if (remain <= 180 && stage < 1) {
lastStage.put(uuid, 1);
p.sendMessage(
"§e§l⚠ 挂机警告 ⚠ §f你已挂机 §c"
+ fmtSec(idle)
+ "§f,还剩 §c"
+ fmtSec(remain)
+ "§f踢出");
p.sendTitle(
"§e§l挂机了 " + fmtSec(idle),
"§f还剩 " + fmtSec(remain) + " 踢出",
10, 60, 20);
for (int i = 0; i < 3; i++) {
final int delay = i * 10;
Bukkit.getScheduler().runTaskLater(
plugin, () -> {
if (p.isOnline()) playSound(p);
}, delay);
}
}
if (remain > 180) {
lastStage.remove(uuid);
}
}
}
/**
* 玩家有活动时调用(移动/交互/放置/挖掘等)
* 重置计时,退出挂机检测
*/
public void recordAction(UUID uuid) {
lastAction.put(uuid, System.currentTimeMillis());
afkTracking.put(uuid, false);
lastStage.put(uuid, 0);
}
/**
* 玩家下线时调用
* 彻底停止计时,清理所有数据
*/
public void remove(UUID uuid) {
lastAction.remove(uuid);
lastStage.remove(uuid);
afkTracking.remove(uuid);
}
/**
* 获取玩家当前是否在挂机检测中
*/
public boolean isAfkTracking(UUID uuid) {
return afkTracking.getOrDefault(uuid, false);
}
private boolean isLoggedIn(String name) {
return ((Main) plugin).getLoggedIn()
.contains(name);
}
private void playSound(Player p) {
p.playSound(p.getLocation(),
Sound.BLOCK_NOTE_BLOCK_CHIME,
1.0f, 0.79f);
p.playSound(p.getLocation(),
Sound.BLOCK_NOTE_BLOCK_BASS,
1.0f, 0.79f);
p.playSound(p.getLocation(),
Sound.BLOCK_NOTE_BLOCK_PLING,
1.0f, 0.79f);
}
private String fmtSec(long sec) {
if (sec < 60) return sec + "秒";
long m = sec / 60;
long s = sec % 60;
return m + "分" + (s > 0 ? s + "秒" : "");
}
}
@@ -0,0 +1,146 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class AccountRequestManager {
public enum ApprovalMode { AUTO, MANUAL, HYBRID }
public static class Request {
public final int id;
public final String requester;
public final String target;
public final String ip;
public final long time;
public String status;
public String denyReason;
public Request(int id, String requester, String target, String ip) {
this.id = id;
this.requester = requester;
this.target = target;
this.ip = ip;
this.time = System.currentTimeMillis();
this.status = "PENDING";
this.denyReason = "";
}
}
private final Main plugin;
private final Map<Integer, Request> requests = new ConcurrentHashMap<>();
private final Map<String, Integer> pendingByPlayer = new ConcurrentHashMap<>();
private int nextId = 1;
private ApprovalMode mode = ApprovalMode.MANUAL;
private int autoDelayMinutes = 60;
public AccountRequestManager(Main plugin) {
this.plugin = plugin;
}
public ApprovalMode getMode() { return mode; }
public void setMode(String m) {
try { this.mode = ApprovalMode.valueOf(m.toUpperCase()); }
catch (Exception e) { this.mode = ApprovalMode.MANUAL; }
}
public void setAutoDelayMinutes(int min) {
this.autoDelayMinutes = min;
}
public Request createRequest(String requester, String target, String ip) {
int id = nextId++;
Request req = new Request(id, requester, target, ip);
requests.put(id, req);
pendingByPlayer.put(requester, id);
if (mode == ApprovalMode.AUTO) {
scheduleAutoApprove(id);
} else if (mode == ApprovalMode.HYBRID) {
String targetIp = (String) plugin.getDb().getField(target, "ip_address");
if (ip != null && ip.equals(targetIp)) {
scheduleAutoApprove(id);
}
}
return req;
}
private void scheduleAutoApprove(int id) {
long delayTicks = (long) autoDelayMinutes * 60L * 20L;
Request req = requests.get(id);
if (req == null) return;
String requesterName = req.requester;
String targetName = req.target;
plugin.getLogger().info("[Sdf1_login] OA#" + id + " 将在 " + autoDelayMinutes + " 分钟后自动通过");
Bukkit.getScheduler().runTaskLater(plugin, () -> {
Request r = requests.get(id);
if (r != null && "PENDING".equals(r.status)) {
approveRequest(id);
plugin.getLogger().info("[Sdf1_login] OA#" + id + " 自动通过");
Player requester = Bukkit.getPlayer(requesterName);
if (requester != null && requester.isOnline()) {
requester.sendMessage("§a[Sdf1_login] §f您的删除请求 #" + id + "(§e" + targetName + "§a)已自动通过!");
}
}
}, delayTicks);
}
public boolean approveRequest(int id) {
Request req = requests.get(id);
if (req == null || !"PENDING".equals(req.status)) return false;
req.status = "APPROVED";
plugin.getDb().deleteUser(req.target);
pendingByPlayer.remove(req.requester);
// 清背包+传送出生点+踢出
Player target = Bukkit.getPlayer(req.target);
if (target != null && target.isOnline()) {
target.getInventory().clear();
target.getInventory().setArmorContents(new org.bukkit.inventory.ItemStack[4]);
target.getInventory().setExtraContents(new org.bukkit.inventory.ItemStack[1]);
target.teleport(target.getWorld().getSpawnLocation());
target.kickPlayer("§c§l你的账号已被管理员删除,请重新注册");
}
return true;
}
public boolean denyRequest(int id, String reason) {
Request req = requests.get(id);
if (req == null || !"PENDING".equals(req.status)) return false;
req.status = "DENIED";
req.denyReason = reason;
pendingByPlayer.remove(req.requester);
return true;
}
public List<Request> getPendingRequests() {
List<Request> list = new ArrayList<>();
for (Request r : requests.values()) {
if ("PENDING".equals(r.status)) list.add(r);
}
return list;
}
public Request getRequest(int id) {
return requests.get(id);
}
public boolean hasPending(String player) {
return pendingByPlayer.containsKey(player);
}
public void cancelPending(String player) {
Integer id = pendingByPlayer.remove(player);
if (id != null) {
Request r = requests.get(id);
if (r != null) r.status = "CANCELLED";
}
}
}
@@ -0,0 +1,594 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.*;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class ChatFilterManager {
private final JavaPlugin plugin;
private final List<String> whitelistUrls =
new ArrayList<>();
private final Set<String> whitelistPlayers =
new TreeSet<>(
String.CASE_INSENSITIVE_ORDER);
private final Map<Integer, String>
punishmentRules =
new LinkedHashMap<>();
private final Map<Integer, Integer>
punishmentDurations =
new LinkedHashMap<>();
private final Map<String, Integer>
violationCount =
new ConcurrentHashMap<>();
private final Map<String, Long>
mutedPlayers =
new ConcurrentHashMap<>();
private final Map<String, String> messages =
new LinkedHashMap<>();
private boolean notifyAdmin = true;
private boolean notifyAll = false;
private int muteDuration = 300;
private static final Pattern URL_PATTERN =
Pattern.compile(
"(?i)(?:https?://\\S+)"
+ "|(?:(?:[a-zA-Z0-9]"
+ "(?:[a-zA-Z0-9\\-]*"
+ "[a-zA-Z0-9])?\\.)+"
+ "[a-zA-Z]{2,})"
+ "(?:/\\S*)?");
private static final Pattern DOT_VARIANTS =
Pattern.compile(
"[\uff0e\u3002\u2025\u2026\u00b7]");
private static final Pattern COLOR_CODES =
Pattern.compile(
"\u00a7[0-9a-fk-orA-FK-OR]");
private static final Set<String> FILE_EXT;
static {
Set<String> s = new HashSet<>(
Arrays.asList(
"txt", "yml", "yaml",
"json", "xml", "java",
"class", "jar", "png",
"jpg", "jpeg", "gif",
"bmp", "svg", "ico",
"webp", "mp3", "mp4",
"avi", "mkv", "wav",
"flac", "ogg", "zip",
"rar", "7z", "tar",
"gz", "cfg", "conf",
"ini", "log", "db",
"sql", "sh", "bat",
"html", "htm", "css",
"js", "md", "pdf",
"dll", "so", "exe",
"bin", "c", "cpp",
"py", "rb", "go",
"rs", "kt", "lua",
"php", "asp", "jsp"));
FILE_EXT = Collections.unmodifiableSet(s);
}
public ChatFilterManager(JavaPlugin plugin) {
this.plugin = plugin;
}
// ========== 配置加载 ==========
public void loadConfig() {
whitelistUrls.clear();
whitelistPlayers.clear();
punishmentRules.clear();
punishmentDurations.clear();
messages.clear();
File f = new File(
plugin.getDataFolder(), "chat.txt");
if (!f.exists()) writeDefaultFile(f);
try (BufferedReader r =
new BufferedReader(
new InputStreamReader(
new FileInputStream(f),
StandardCharsets.UTF_8))) {
String section = "";
String line;
while ((line = r.readLine()) != null) {
if (line.startsWith("\uFEFF"))
line = line.substring(1);
String t = line.trim();
if (t.isEmpty()
|| t.startsWith("#"))
continue;
if (t.equals("白名单")
|| t.equals("白名单:")) {
section = "whitelist";
continue;
}
if (t.equals("处罚规则")
|| t.equals("处罚规则:")) {
section = "punishment";
continue;
}
if (t.equals("白名单玩家")
|| t.equals("白名单玩家:")) {
section = "players";
continue;
}
if (t.contains(":")) {
String[] kv = t.split(":", 2);
String k = kv[0].trim();
String v = kv[1].trim();
if (k.equals("通知管理员")) {
notifyAdmin = parseBool(v);
continue;
}
if (k.equals("全服通报")) {
notifyAll = parseBool(v);
continue;
}
if (k.equals("禁言时长")) {
try {
muteDuration =
Integer.parseInt(v);
} catch (Exception ignored) {
}
continue;
}
}
switch (section) {
case "whitelist":
whitelistUrls.add(
t.toLowerCase());
break;
case "punishment":
String[] parts = t.split(":");
if (parts.length >= 2) {
try {
int cnt =
Integer.parseInt(
parts[0]
.trim());
String type =
parts[1].trim()
.toLowerCase();
int dur =
parts.length >= 3
? Integer.parseInt(
parts[2]
.trim())
: muteDuration;
punishmentRules.put(
cnt, type);
punishmentDurations.put(
cnt, dur);
} catch (Exception ignored) {
}
}
break;
case "players":
whitelistPlayers.add(t);
break;
}
}
} catch (Exception e) {
plugin.getLogger().severe(
"[Sdf1_chat] 配置加载失败: "
+ e.getMessage());
}
// 从消息.txt读取chat_开头的消息
loadMessages();
}
private void loadMessages() {
File msgFile = new File(
plugin.getDataFolder(),
"消息.txt");
if (!msgFile.exists()) return;
try (BufferedReader mr =
new BufferedReader(
new InputStreamReader(
new FileInputStream(
msgFile),
StandardCharsets.UTF_8))) {
String mline;
while ((mline = mr.readLine()) != null) {
mline = mline.trim();
if (mline.isEmpty()
|| mline.startsWith("#"))
continue;
// 支持 = 和 : 两种分隔符
int eqIdx = mline.indexOf('=');
int coIdx = mline.indexOf(':');
int splitIdx = -1;
if (eqIdx >= 0 && coIdx >= 0) {
splitIdx = Math.min(eqIdx, coIdx);
} else if (eqIdx >= 0) {
splitIdx = eqIdx;
} else if (coIdx >= 0) {
splitIdx = coIdx;
}
if (splitIdx < 0) continue;
String k = mline.substring(0,
splitIdx).trim();
String v = mline.substring(
splitIdx + 1).trim();
if (k.startsWith("chat_")) {
messages.put(k, v);
}
}
} catch (Exception ignored) {
}
}
private void writeDefaultFile(File f) {
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets.UTF_8))) {
pw.println("# Sdf1_chat 聊天过滤配置");
pw.println("通知管理员: true");
pw.println("全服通报: false");
pw.println("禁言时长: 300");
pw.println();
pw.println("白名单:");
pw.println("*.minecraft.net");
pw.println("baidu.com");
pw.println("github.com");
pw.println();
pw.println("处罚规则:");
pw.println("1:warn:0");
pw.println("2:warn:0");
pw.println("3:mute:300");
pw.println("5:mute:600");
pw.println("8:kick:0");
pw.println("10:ban:0");
pw.println();
pw.println("白名单玩家:");
} catch (IOException ignored) {
}
}
public void saveConfig() {
File f = new File(
plugin.getDataFolder(), "chat.txt");
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets.UTF_8))) {
pw.println("# Sdf1_chat 聊天过滤配置");
pw.println("通知管理员: " + notifyAdmin);
pw.println("全服通报: " + notifyAll);
pw.println("禁言时长: " + muteDuration);
pw.println();
pw.println("白名单:");
for (String u : whitelistUrls)
pw.println(u);
pw.println();
pw.println("处罚规则:");
for (Map.Entry<Integer, String> entry
: punishmentRules.entrySet()) {
int dur = punishmentDurations
.getOrDefault(entry.getKey(),
muteDuration);
pw.println(entry.getKey() + ":"
+ entry.getValue()
+ ":" + dur);
}
pw.println();
pw.println("白名单玩家:");
for (String p : whitelistPlayers)
pw.println(p);
} catch (IOException e) {
plugin.getLogger().severe(
"[Sdf1_chat] 保存失败: "
+ e.getMessage());
}
}
// ========== 消息 ==========
public String msg(String key,
String... args) {
String template = messages.getOrDefault(
key, key);
for (int i = 0; i < args.length - 1;
i += 2) {
template = template.replace(
"{" + args[i] + "}",
args[i + 1]);
}
return template;
}
// ========== URL检测 ==========
private String normalizeMessage(String msg) {
msg = COLOR_CODES.matcher(msg)
.replaceAll("");
msg = DOT_VARIANTS.matcher(msg)
.replaceAll(".");
return msg;
}
public List<String> extractUrls(
String message) {
String normalized =
normalizeMessage(message);
List<String> urls = new ArrayList<>();
Matcher m = URL_PATTERN
.matcher(normalized);
while (m.find()) {
String url = m.group().toLowerCase();
url = url.replaceAll(
"[\\s.,;!?,。;!?、)》」』】"
+ "\\]\\[\\((]+$",
"");
if (!url.isEmpty()) urls.add(url);
}
return urls;
}
public boolean isWhitelisted(String url) {
String clean = url
.replaceAll("^https?://", "")
.toLowerCase();
int colon = clean.indexOf(':');
int slash = clean.indexOf('/');
if (colon > 0 && (slash < 0
|| colon < slash)) {
clean = clean.substring(0, colon);
}
slash = clean.indexOf('/');
if (slash > 0) {
clean = clean.substring(0, slash);
}
for (String entry : whitelistUrls) {
String e = entry.toLowerCase().trim();
if (e.startsWith("*.")) {
String suffix = e.substring(1);
if (clean.endsWith(suffix)
|| clean.equals(
suffix.substring(1)))
return true;
} else {
if (clean.equals(e)
|| clean.endsWith(
"." + e))
return true;
}
}
return false;
}
public boolean isLikelyDomain(String url) {
int lastDot = url.lastIndexOf('.');
if (lastDot < 0
|| lastDot >= url.length() - 1)
return false;
String ext = url.substring(lastDot + 1)
.toLowerCase();
return !FILE_EXT.contains(ext);
}
// ========== 玩家操作 ==========
public boolean isMuted(String name) {
Long expiry = mutedPlayers.get(name);
if (expiry == null) return false;
if (System.currentTimeMillis()
>= expiry) {
mutedPlayers.remove(name);
return false;
}
return true;
}
public String fmtDuration(int seconds) {
if (seconds <= 0) return "0秒";
int day = seconds / 86400;
int hr = (seconds % 86400) / 3600;
int min = (seconds % 3600) / 60;
int sec = seconds % 60;
StringBuilder sb = new StringBuilder();
if (day > 0) sb.append(day).append("天");
if (hr > 0)
sb.append(hr).append("小时");
if (min > 0)
sb.append(min).append("分");
if (sec > 0 || sb.length() == 0)
sb.append(sec).append("秒");
return sb.toString();
}
public void applyPunishment(Player player,
int violation) {
String type = "warn";
int duration = muteDuration;
int maxRule = 0;
for (Map.Entry<Integer, String> entry
: punishmentRules.entrySet()) {
if (violation >= entry.getKey()
&& entry.getKey() > maxRule) {
maxRule = entry.getKey();
type = entry.getValue();
duration = punishmentDurations
.getOrDefault(
entry.getKey(),
muteDuration);
}
}
if (type.equals("mute")) {
if (isMuted(player.getName()))
return;
mutedPlayers.put(player.getName(),
System.currentTimeMillis()
+ (long) duration
* 1000L);
}
final String fType = type;
final int fDur = duration;
final int fV = violation;
final String fName = player.getName();
Bukkit.getScheduler().runTask(plugin,
() -> {
Player p = Bukkit.getPlayer(fName);
if (p == null) return;
switch (fType) {
case "warn":
// 警告不处罚,只提示
break;
case "mute":
p.sendMessage(
msg("chat_muted"));
break;
case "kick":
p.kickPlayer(
msg("chat_muted"));
break;
case "ban":
Bukkit.dispatchCommand(
Bukkit.getConsoleSender(),
"ban " + fName
+ " §c多次发送违规链接");
break;
case "banip":
InetSocketAddress addr =
p.getAddress();
if (addr != null) {
String ip = addr
.getAddress()
.getHostAddress();
Bukkit.dispatchCommand(
Bukkit.getConsoleSender(),
"ban-ip " + ip
+ " §c发送违规链接");
}
break;
}
});
}
public void unmutePlayer(String name) {
mutedPlayers.remove(name);
}
public void resetPlayer(String name) {
mutedPlayers.remove(name);
violationCount.remove(name);
}
public boolean isPlayerWhitelisted(
String name) {
return whitelistPlayers.contains(name);
}
public boolean isNotifyAdmin() {
return notifyAdmin;
}
public boolean isNotifyAll() {
return notifyAll;
}
public int getViolationCount(String name) {
return violationCount
.getOrDefault(name, 0);
}
public void incrementViolation(
String name) {
violationCount.put(name,
getViolationCount(name) + 1);
}
public List<String> getWhitelistUrls() {
return whitelistUrls;
}
public Set<String> getWhitelistPlayers() {
return whitelistPlayers;
}
public Map<Integer, String>
getPunishmentRules() {
return punishmentRules;
}
public Map<Integer, Integer>
getPunishmentDurations() {
return punishmentDurations;
}
public Map<String, Long>
getMutedPlayers() {
return mutedPlayers;
}
public void setNotifyAdmin(boolean v) {
notifyAdmin = v;
}
public void setNotifyAll(boolean v) {
notifyAll = v;
}
public void setMuteDuration(int v) {
muteDuration = v;
}
public void addUrl(String url) {
whitelistUrls.add(
url.toLowerCase().trim());
saveConfig();
}
public boolean removeUrl(String url) {
boolean r = whitelistUrls.remove(
url.toLowerCase().trim());
saveConfig();
return r;
}
public void addWhitelistPlayer(
String name) {
whitelistPlayers.add(name);
saveConfig();
}
public boolean removeWhitelistPlayer(
String name) {
boolean r =
whitelistPlayers.remove(name);
saveConfig();
return r;
}
public void cleanExpired() {
long now = System.currentTimeMillis();
mutedPlayers.entrySet().removeIf(
e -> e.getValue() <= now);
}
private boolean parseBool(String v) {
String s = v.toLowerCase().trim();
return s.equals("true")
|| s.equals("on")
|| s.equals("yes")
|| s.equals("1")
|| s.contains("开启")
|| s.contains("启用")
|| s.contains("是");
}
}
@@ -0,0 +1,824 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.HashMap;
import java.util.Map;
public class ChatInputManager {
public enum InputType {
NONE,
CHANGE_PWD_STEP1,
CHANGE_PWD_STEP2,
SET_EMAIL,
SET_EMAIL_VERIFY,
ADMIN_AUTH,
ADMIN_SET_POINTS,
ADMIN_SET_PWD,
ADMIN_DELETE_CONFIRM,
ADMIN_SET_AFK_TIME,
SMTP_CONFIG,
SMTP_VERIFY,
BACK_CHECKIN,
INVITE_INPUT_CODE,
TICKET_TITLE,
TICKET_DESC,
TICKET_SCORE,
TICKET_REJECT,
TICKET_ASSIGN,
TICKET_INVITE_PROVIDER,
TICKET_REMOVE_PROVIDER,
TICKET_COMPLETE,
TICKET_REPORT_ISSUE,
TICKET_REPLY,
TICKET_ADMIN_TYPE,
TICKET_ADMIN_TITLE,
TICKET_ADMIN_DESC,
TICKET_ADMIN_PROVIDER
}
public static class PlayerState {
public InputType type = InputType.NONE;
public String targetPlayer = "";
public String ticketType = "";
public String ticketTitle = "";
public String ticketDesc = "";
public int ticketId = 0;
public int smtpStep = 0;
public int scoreValue = 0;
}
private final Map<String, PlayerState> states =
new HashMap<>();
private Main plugin;
public PlayerState getState(Player p) {
return states.computeIfAbsent(
p.getName(),
k -> new PlayerState());
}
public void reset(Player p) {
states.remove(p.getName());
}
public void handleInput(Player p, String msg,
Main mainPlugin) {
if (this.plugin == null)
this.plugin = mainPlugin;
PlayerState state = getState(p);
ConfigManager config =
mainPlugin.getConfig2();
DatabaseManager db = mainPlugin.getDb();
switch (state.type) {
case CHANGE_PWD_STEP1: {
String name = p.getName();
String salt = (String) db.getField(
name, "password_salt");
String hash =
PasswordUtils.hash(msg, salt);
boolean mainOk =
db.checkPassword(name, hash);
if (mainOk) {
state.type =
InputType.CHANGE_PWD_STEP2;
state.ticketTitle = "main";
p.sendMessage(
"§e请输入新密码:");
return;
}
boolean tempOk =
db.checkPasswordOrTemp(
name, hash);
if (tempOk) {
state.type =
InputType.CHANGE_PWD_STEP2;
state.ticketTitle = "temp";
p.sendMessage(
"§e请输入新密码:");
return;
}
p.sendMessage(config.msg(
"password_wrong"));
return;
}
case CHANGE_PWD_STEP2: {
String name = p.getName();
if (!PasswordUtils.validate(msg)) {
p.sendMessage(config.msg(
"password_format_error"));
reset(p);
return;
}
String oldHash =
(String) db.getField(name,
"password_hash");
String oldSalt =
(String) db.getField(name,
"password_salt");
String newSalt =
PasswordUtils.generateSalt();
String newHash =
PasswordUtils.hash(
msg, newSalt);
if (newHash.equals(oldHash)) {
p.sendMessage(config.msg(
"password_same"));
reset(p);
return;
}
mainPlugin.recordPasswordChange(
name, oldHash, oldSalt);
db.setField(name,
"password_hash", newHash);
db.setField(name,
"password_salt", newSalt);
mainPlugin
.getNeedsPasswordChange()
.remove(name);
p.sendMessage(config.msg(
"password_changed"));
reset(p);
return;
}
case SET_EMAIL: {
if (!msg.contains("@")
|| !msg.contains(".")) {
p.sendMessage(config.msg(
"email_format_error"));
reset(p);
return;
}
String oldEmail = (String)
db.getField(p.getName(),
"email");
String emailAddr = msg.trim();
// 已绑定邮箱:验证登录密码
if (oldEmail != null
&& !oldEmail.isEmpty()) {
state.targetPlayer = emailAddr;
state.type =
InputType.SET_EMAIL_VERIFY;
state.ticketTitle =
"need_pwd";
p.sendMessage(
"§e您已绑定邮箱: "
+ oldEmail);
p.sendMessage(
"§e请验证登录密码以确认修改:");
return;
}
// 未绑定邮箱:直接发验证码
String code = String.valueOf(
(int) (Math.random()
* 900000
+ 100000));
state.targetPlayer = emailAddr;
state.ticketTitle = code;
state.type =
InputType.SET_EMAIL_VERIFY;
final String to = emailAddr;
final String c = code;
final Player fp = p;
Bukkit.getScheduler()
.runTaskAsynchronously(
mainPlugin, () -> {
boolean sent = mainPlugin
.getEmail()
.sendVerifyCode(
to, c);
Bukkit.getScheduler()
.runTask(mainPlugin,
() -> {
if (sent) {
fp.sendMessage(
"§a验证码已发送到 "
+ to);
fp.sendMessage(
"§e输入6位验证码(0取消):");
} else {
fp.sendMessage(
"§c发送失败");
reset(fp);
}
});
});
return;
}
case SET_EMAIL_VERIFY: {
if ("0".equals(msg.trim())
|| "取消".equals(
msg.trim())) {
p.sendMessage(
"§c邮箱设置已取消");
reset(p);
return;
}
String expected =
state.ticketTitle;
if (msg.trim().equals(expected)) {
db.setField(p.getName(),
"email",
state.targetPlayer);
p.sendMessage(
"§a邮箱已设置: "
+ state.targetPlayer);
reset(p);
} else {
p.sendMessage(
"§c验证码错误(0取消):");
}
return;
}
case ADMIN_AUTH: {
if (msg.equals(
config.adminPassword)) {
Bukkit.getScheduler().runTask(
mainPlugin, () ->
mainPlugin
.getGui()
.openAdmin(p));
} else {
p.sendMessage(
"§c管理密码错误");
}
reset(p);
return;
}
case ADMIN_SET_POINTS: {
try {
int pts = Integer.parseInt(
msg.trim());
db.setField(
state.targetPlayer,
"points", pts);
p.sendMessage("§a已将 "
+ state.targetPlayer
+ " 积分设为 " + pts);
} catch (
NumberFormatException e) {
p.sendMessage(
"§c请输入有效数字");
}
String tgt =
state.targetPlayer;
Bukkit.getScheduler().runTask(
mainPlugin, () ->
mainPlugin
.getGui()
.openUserDetail(
p, tgt));
reset(p);
return;
}
case ADMIN_SET_PWD: {
if (!PasswordUtils.validate(msg)) {
p.sendMessage(config.msg(
"password_format_error"));
reset(p);
return;
}
String tgt = state.targetPlayer;
String salt =
PasswordUtils
.generateSalt();
String hash =
PasswordUtils.hash(
msg, salt);
db.setField(tgt,
"temp_password", hash);
db.setField(tgt, "temp_pw_expire",
System.currentTimeMillis()
+ 300000L);
db.setField(tgt,
"temp_pw_used", 0);
p.sendMessage("§a已为 " + tgt
+ " 设置临时密码");
Player tp =
Bukkit.getPlayer(tgt);
if (tp != null && tp.isOnline())
tp.kickPlayer(
"§c管理员已设置临时密码,"
+ "请使用临时密码登录");
// 邮件通知
String emailAddr = (String)
db.getField(tgt, "email");
if (emailAddr != null
&& !emailAddr.isEmpty()) {
String time =
new java.text.SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss")
.format(new java.util.Date());
String body = "玩家 " + tgt
+ ":\n您的密码已于 "
+ time
+ " 被管理员重置为临时密码。\n"
+ "如果是本人操作请忽略。\n"
+ "如非本人操作请立即联系管理员。";
final String to = emailAddr;
final String fBody = body;
final String fName = tgt;
Bukkit.getScheduler()
.runTaskAsynchronously(
mainPlugin, () ->
mainPlugin.getEmail()
.sendTempPassword(
to,
fName,
fBody));
}
reset(p);
return;
}
case ADMIN_DELETE_CONFIRM: {
if (msg.equals(
state.targetPlayer)) {
db.deleteUser(
state.targetPlayer);
p.sendMessage("§a已删除 "
+ state.targetPlayer);
Player tgt =
Bukkit.getPlayer(
state.targetPlayer);
if (tgt != null
&& tgt.isOnline())
tgt.kickPlayer(
"§c账号已被删除");
} else {
p.sendMessage(
"§c输入不匹配,已取消");
}
reset(p);
return;
}
case ADMIN_SET_AFK_TIME: {
try {
int minutes =
Integer.parseInt(
msg.trim());
config.afkTimeout =
minutes * 60;
config.saveSettings();
p.sendMessage(
"§a挂机时长已设为 "
+ minutes
+ " 分钟");
} catch (
NumberFormatException e) {
p.sendMessage(
"§c请输入有效数字");
}
reset(p);
return;
}
case SMTP_CONFIG: {
String m = msg.trim();
if ("000".equals(m)
|| "取消取消取消"
.equals(m)) {
p.sendMessage(
"§cSMTP配置已取消");
reset(p);
return;
}
if ("取消".equals(m)) {
p.sendMessage(
"§cSMTP配置已取消");
reset(p);
return;
}
int step = state.smtpStep;
if (step <= 1) {
if (!"0".equals(m))
config.setSmtp(
"smtp地址", m);
state.smtpStep = 2;
p.sendMessage("§e当前端口: "
+ config.getSmtp(
"smtp端口"));
p.sendMessage(
"§e输入端口(0跳过):");
} else if (step == 2) {
if (!"0".equals(m))
config.setSmtp(
"smtp端口", m);
state.smtpStep = 3;
p.sendMessage(
"§e输入发件邮箱:");
} else if (step == 3) {
if (!"0".equals(m))
config.setSmtp(
"smtp账号", m);
state.smtpStep = 4;
p.sendMessage(
"§e输入SMTP密码(0跳过):");
} else if (step == 4) {
if (!"0".equals(m))
config.setSmtp(
"smtp密码", m);
state.smtpStep = 5;
p.sendMessage(
"§e输入接收邮箱(留空发到发件邮箱):");
} else if (step == 5) {
String recv = m;
if (recv.isEmpty()
|| "0".equals(recv))
recv = config.getSmtp(
"smtp账号");
config.setSmtp(
"验证码接收邮箱", recv);
if (!config
.canSendSmtpVerify()) {
p.sendMessage(
"§c发送间隔不足1分钟");
reset(p);
return;
}
String code = config
.generateSmtpVerifyCode();
String addr = config.getSmtp(
"smtp地址");
String acct = config.getSmtp(
"smtp账号");
if (acct.isEmpty()
|| addr.isEmpty()) {
p.sendMessage(
"§c地址或账号为空");
reset(p);
return;
}
config.recordSmtpSendTime();
final String fRecv = recv;
final String fCode = code;
final Player fp = p;
Bukkit.getScheduler()
.runTaskAsynchronously(
mainPlugin, () -> {
Bukkit.getScheduler()
.runTask(
mainPlugin,
() -> fp.sendMessage(
"§e正在发送验证码..."));
boolean sent =
mainPlugin.getEmail()
.sendVerifyCode(
fRecv,
fCode);
final boolean fSent = sent;
Bukkit.getScheduler()
.runTask(
mainPlugin,
() -> {
if (fSent) {
state.type =
InputType
.SMTP_VERIFY;
state.smtpStep = 0;
fp.sendMessage(
"§a验证码已发送到 "
+ fRecv);
fp.sendMessage(
"§e输入6位验证码(0取消):");
} else {
fp.sendMessage(
"§c发送失败,查看控制台");
reset(fp);
}
});
});
}
return;
}
case SMTP_VERIFY: {
if ("0".equals(msg.trim())
|| "取消".equals(
msg.trim())) {
p.sendMessage(
"§cSMTP配置已取消");
reset(p);
return;
}
if (config.checkSmtpVerifyCode(
msg.trim())) {
config.saveSmtp();
config.clearSmtpVerifyCode();
String addr = config.getSmtp(
"smtp地址");
String port = config.getSmtp(
"smtp端口");
String acct = config.getSmtp(
"smtp账号");
String recv = config.getSmtp(
"验证码接收邮箱");
boolean ssl = config.smtpSsl;
p.sendMessage("§a§l=== SMTP配置完成 ===");
p.sendMessage("§7地址: §f" + addr);
p.sendMessage("§7端口: §f" + port);
p.sendMessage("§7账号: §f" + acct);
p.sendMessage("§7接收: §f" + recv);
p.sendMessage("§7加密: §f" + ssl);
p.sendMessage("§a§l====================");
reset(p);
} else {
p.sendMessage(
"§c验证码错误(0取消):");
}
return;
}
case BACK_CHECKIN: {
int cost = 20;
if (!mainPlugin.getDb()
.deductPoints(
p.getName(), cost)) {
p.sendMessage(
"§c积分不足,补签需 "
+ cost + " 积分");
reset(p);
return;
}
p.sendMessage(
"§a补签成功!扣除 "
+ cost + " 积分。"
+ "日期: " + msg);
reset(p);
return;
}
case INVITE_INPUT_CODE: {
String code = msg.trim();
String myName = p.getName();
String myCode =
(String) db.getField(
myName,
"invite_code");
if (code.isEmpty()) {
p.sendMessage(
"§c邀请码不能为空");
reset(p);
return;
}
if (code.equals(myCode)) {
p.sendMessage(
"§c不能用自己邀请码");
reset(p);
return;
}
boolean found = false;
for (Map<String, Object> u :
db.getAllUsers()) {
String uc = (String)
u.getOrDefault(
"invite_code", "");
if (code.equals(uc)) {
String inv = (String)
u.get("player_name");
db.setField(myName,
"invited_by", inv);
db.addPoints(inv, 50);
p.sendMessage(
"§a邀请成功!邀请人: "
+ inv);
Player ip =
Bukkit.getPlayer(inv);
if (ip != null
&& ip.isOnline())
ip.sendMessage("§a"
+ myName
+ " 使用了您的邀请码");
found = true;
break;
}
}
if (!found)
p.sendMessage(
"§c邀请码不存在");
reset(p);
return;
}
case TICKET_TITLE: {
state.ticketTitle = msg;
state.type =
InputType.TICKET_DESC;
p.sendMessage(
"§e请输入工单详细描述:");
return;
}
case TICKET_DESC: {
String tt = state.ticketType;
String tl = state.ticketTitle;
reset(p);
mainPlugin.getTicket()
.submitTicket(
p, tt, tl, msg);
return;
}
case TICKET_SCORE: {
int tid = state.ticketId;
try {
int sc = Integer.parseInt(
msg.trim());
reset(p);
mainPlugin.getTicket()
.confirmAndScore(
p, tid, sc);
} catch (
NumberFormatException e) {
reset(p);
p.sendMessage(
"§c请输入数字1-5");
}
return;
}
case TICKET_REJECT: {
int tid = state.ticketId;
reset(p);
mainPlugin.getTicket()
.rejectTicket(
p, tid, msg);
return;
}
case TICKET_ASSIGN: {
int tid = state.ticketId;
String[] parts = msg.trim()
.split("\\s+");
String prov = parts[0];
int price = 0;
if (parts.length >= 2) {
try {
price = Integer.parseInt(
parts[1]);
} catch (
NumberFormatException e) {
p.sendMessage(
"§c价格格式错误");
}
}
reset(p);
mainPlugin.getTicket()
.assignToProvider(
p, tid, prov,
price);
final int fTid = tid;
Bukkit.getScheduler().runTask(
mainPlugin, () ->
mainPlugin
.getTicket()
.openDetail(
p, fTid));
return;
}
case TICKET_INVITE_PROVIDER: {
String tgt = msg.trim();
reset(p);
if (!db.userExists(tgt)) {
p.sendMessage(
"§c玩家不存在");
return;
}
db.addServiceProvider(
tgt, "waiter");
p.sendMessage("§a已邀请 " + tgt
+ " 成为服务商");
return;
}
case TICKET_REMOVE_PROVIDER: {
String tgt = msg.trim();
reset(p);
if (!db.isServiceProvider(tgt)) {
p.sendMessage("§c" + tgt
+ " 不是服务商");
return;
}
db.removeServiceProvider(tgt);
p.sendMessage("§a已移除 " + tgt);
return;
}
case TICKET_COMPLETE: {
try {
int tid = Integer.parseInt(
msg.trim());
mainPlugin.getTicket()
.completeAsProvider(
p, tid);
} catch (
NumberFormatException e) {
p.sendMessage(
"§c请输入有效工单号");
}
reset(p);
return;
}
case TICKET_REPORT_ISSUE: {
String report = msg.trim();
reset(p);
p.sendMessage("§e异常已上报");
String tag = config.adminTag;
for (Player op :
Bukkit.getOnlinePlayers()) {
if (op.getScoreboardTags()
.contains(tag)) {
op.sendMessage(
"§c§l[工单异常] §f"
+ p.getName()
+ ": " + report);
}
}
return;
}
case TICKET_REPLY: {
int replyTid = state.ticketId;
reset(p);
mainPlugin.getTicket()
.replyTicket(
p, replyTid, msg);
return;
}
case TICKET_ADMIN_TYPE: {
state.ticketType = msg.trim();
state.type =
InputType
.TICKET_ADMIN_TITLE;
p.sendMessage(
"§e请输入工单标题:");
return;
}
case TICKET_ADMIN_TITLE: {
state.ticketTitle = msg;
state.type =
InputType
.TICKET_ADMIN_DESC;
p.sendMessage(
"§e请输入工单描述:");
return;
}
case TICKET_ADMIN_DESC: {
state.targetPlayer = msg;
state.type =
InputType
.TICKET_ADMIN_PROVIDER;
p.sendMessage(
"§e输入服务商名和价格:");
p.sendMessage(
"§7例: playerA 20");
return;
}
case TICKET_ADMIN_PROVIDER: {
String[] parts2 = msg.trim()
.split("\\s+");
String prov2 = parts2[0];
int price2 = 0;
if (parts2.length >= 2) {
try {
price2 = Integer.parseInt(
parts2[1]);
} catch (
NumberFormatException e) {
}
}
String t2 = state.ticketType;
String ti2 = state.ticketTitle;
String d2 = state.targetPlayer;
reset(p);
mainPlugin.getTicket()
.adminCreateAndAssign(
p, t2, ti2,
d2, prov2, price2);
return;
}
default:
reset(p);
return;
}
}
}
@@ -0,0 +1,114 @@
package Sdf1_login;
import org.bukkit.entity.Player;
import java.text.SimpleDateFormat;
import java.util.Date;
public class CheckInManager {
private final Main plugin;
public CheckInManager(Main plugin) {
this.plugin = plugin;
}
public boolean isCheckedInToday(String name) {
String last = (String) plugin.getDb()
.getField(name, "last_checkin_date");
String today = new SimpleDateFormat("yyyy-MM-dd")
.format(new Date());
return today.equals(last);
}
public String checkIn(Player p) {
String name = p.getName();
if (isCheckedInToday(name)) {
return plugin.getConfig2()
.msg("checkin_already");
}
Object streakObj = plugin.getDb()
.getField(name, "checkin_streak");
int streak = streakObj != null
? ((Number) streakObj).intValue() : 0;
String lastDate = (String) plugin.getDb()
.getField(name, "last_checkin_date");
String today = new SimpleDateFormat("yyyy-MM-dd")
.format(new Date());
if (lastDate != null && !lastDate.isEmpty()) {
try {
java.util.Date lastD = new SimpleDateFormat(
"yyyy-MM-dd").parse(lastDate);
java.util.Date todayD = new SimpleDateFormat(
"yyyy-MM-dd").parse(today);
long diff = todayD.getTime() - lastD.getTime();
if (diff > 86400000L * 2) {
streak = 0;
} else if (diff > 86400000L) {
streak++;
}
} catch (Exception ignored) {
}
}
if (streak == 0) streak = 1;
else streak++;
double multi = 1.0;
if (streak >= 21) multi = 1.5;
else if (streak >= 14) multi = 1.3;
else if (streak >= 7) multi = 1.1;
int points = (int) (1 * multi);
plugin.getDb().addPoints(name, points);
plugin.getDb().setField(
name, "checkin_streak", streak);
plugin.getDb().setField(
name, "last_checkin_date", today);
Object totalObj = plugin.getDb()
.getField(name, "total_checkin_days");
int total = totalObj != null
? ((Number) totalObj).intValue() : 0;
plugin.getDb().setField(
name, "total_checkin_days", total + 1);
return plugin.getConfig2()
.msg("checkin_success")
.replace("{points}",
String.valueOf(points))
.replace("{streak}",
String.valueOf(streak))
.replace("{multi}",
String.valueOf(multi));
}
public String backCheckIn(Player p, String dateStr) {
String name = p.getName();
if (!plugin.getDb().deductPoints(name, 10)) {
return plugin.getConfig2()
.msg("checkin_already");
}
try {
java.util.Date target = new SimpleDateFormat(
"yyyy-MM-dd").parse(dateStr);
java.util.Date now = new SimpleDateFormat(
"yyyy-MM-dd").parse(
new SimpleDateFormat("yyyy-MM-dd")
.format(new Date()));
long diff = now.getTime() - target.getTime();
if (diff < 0 || diff > 3 * 86400000L) {
plugin.getDb().addPoints(name, 10);
return plugin.getConfig2()
.msg("checkin_already");
}
plugin.getDb().addPoints(name, 1);
return "§a补签成功!日期: " + dateStr;
} catch (Exception e) {
plugin.getDb().addPoints(name, 10);
return "§c日期格式错误,请用 yyyy-MM-dd";
}
}
}
@@ -0,0 +1,227 @@
package Sdf1_login;
import org.bukkit.entity.Player;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.logging.Logger;
public class CommissionManager {
private final Main main;
private final Logger logger;
private final List<double[]> rules =
new ArrayList<>();
public CommissionManager(Main main) {
this.main = main;
this.logger = main.getLogger();
loadRules();
}
public void loadRules() {
rules.clear();
File f = new File(
main.getDataFolder(),
"提成规则.txt");
if (!f.exists()) {
writeDefault(f);
}
try (BufferedReader br =
new BufferedReader(
new InputStreamReader(
new FileInputStream(f),
StandardCharsets
.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
line = line.trim();
if (line.isEmpty()
|| line.startsWith("#"))
continue;
// 格式: 邀请人数:提成百分比
// 如 "5:15" = 邀请5人提成15%
String[] parts =
line.split(":");
if (parts.length >= 2) {
try {
int count = Integer.parseInt(
parts[0].trim());
double rate = Double
.parseDouble(
parts[1]
.trim()) / 100.0;
rules.add(new double[]{
count, rate});
} catch (Exception ignored) {
}
}
}
// 按人数排序(从大到小)
rules.sort((a, b) ->
Double.compare(b[0], a[0]));
logger.info(
"[Commission] 加载 "
+ rules.size() + " 条提成规则");
} catch (Exception e) {
logger.warning(
"[Commission] 加载失败: "
+ e.getMessage());
}
}
private void writeDefault(File f) {
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets.UTF_8))) {
pw.println("# 邀请提成规则");
pw.println("# 格式: 邀请人数:提成百分比");
pw.println("# 邀请10人提成20%");
pw.println("10:20");
pw.println("# 邀请5人提成15%");
pw.println("5:15");
pw.println("# 邀请1人提成10%");
pw.println("1:10");
} catch (IOException ignored) {
}
}
/**
* 获取邀请人的提成比例
* 根据已邀请人数匹配最高规则
*/
public double getCommissionRate(
String inviter) {
int inviteCount = getInviteCount(
inviter);
for (double[] rule : rules) {
if (inviteCount >= (int) rule[0]) {
return rule[1];
}
}
return 0;
}
/**
* 获取邀请人数
*/
public int getInviteCount(
String inviter) {
try {
Object val =
main.getDb().getField(
inviter,
"monthly_invite_count");
if (val instanceof Number) {
return ((Number) val).intValue();
}
Object total =
main.getDb().getField(
inviter,
"total_invite_count");
if (total instanceof Number) {
return ((Number) total).intValue();
}
} catch (Exception ignored) {
}
return 0;
}
/**
* 发放提成
* 被邀请人获得奖励时,邀请人获得提成
*/
public void payCommission(
String invitee,
double originalAmount) {
// 查找邀请人
String inviter = getInviter(invitee);
if (inviter == null
|| inviter.isEmpty()) return;
double rate = getCommissionRate(
inviter);
if (rate <= 0) return;
double commission =
originalAmount * rate;
if (commission <= 0) return;
// 通过 Vault 发放
var rsp = main.getServer()
.getServicesManager()
.getRegistration(
net.milkbowl.vault
.economy.Economy.class);
if (rsp == null) return;
net.milkbowl.vault.economy.Economy econ =
rsp.getProvider();
if (econ == null) return;
econ.depositPlayer(inviter, commission);
// 通知邀请人
Player inviterP =
main.getServer()
.getPlayer(inviter);
if (inviterP != null
&& inviterP.isOnline()) {
inviterP.sendMessage(
"§a§l[邀请提成] §f"
+ invitee + " 完成任务,"
+ "你获得 §e"
+ String.format("%.2f",
commission)
+ " §f游戏币提成 ("
+ (int)(rate * 100) + "%)");
}
logger.info(
"[Commission] 提成: "
+ inviter + " ← "
+ invitee + " | "
+ String.format("%.2f",
originalAmount)
+ " × "
+ (int)(rate * 100) + "% = "
+ String.format("%.2f",
commission));
}
/**
* 获取邀请人
*/
private String getInviter(String player) {
try {
Object val =
main.getDb().getField(
player,
"invited_by");
if (val != null
&& !val.toString()
.isEmpty()) {
return val.toString();
}
} catch (Exception ignored) {
}
return null;
}
/**
* 判断金额是否包含经济内容
*/
public boolean hasEconomyContent(
String text) {
String t = text.toLowerCase();
return t.contains("游戏币")
|| t.contains("金币")
|| t.contains("积分")
|| t.contains("块钱")
|| t.contains("元")
|| t.contains("$")
|| t.contains("经济")
|| t.contains("余额");
}
}
+462
View File
@@ -0,0 +1,462 @@
package Sdf1_login;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import static java.lang.Double.parseDouble;
public class ConfigManager {
private final File dataFolder;
private final Map<String, String> messages = new LinkedHashMap<>();
private final Map<String, String> smtpSettings = new LinkedHashMap<>();
public String adminTag = "admin";
public String adminPassword = "qweasd";
public int maxAccountsPerIP = 3;
public String approvalMode = "manual";
public int autoApproveDelayMinutes = 30;
public int loginTimeout = 60;
public boolean afkEnabled = false;
public int afkTimeout = 300;
public boolean smtpSsl = true;
public String smtpVerifyCode = "";
public long smtpVerifyExpire = 0;
public long smtpLastSendTime = 0;
public String smtpConfiguringPlayer = "";
public ConfigManager(File dataFolder) {
this.dataFolder = dataFolder;
}
// ==================== 消息 ====================
public void loadMessages() {
messages.clear();
File file = new File(dataFolder, "消息.txt");
if (!file.exists()) createDefaultMessages(file);
loadMap(file, messages);
}
public String msg(String key) {
String val = messages.getOrDefault(key, key);
val = val.replace("<b>", "&l");
val = val.replace("</b>", "&r");
val = val.replace("<br>", "\n");
val = val.replace("\\n", "\n");
// &颜色码 转换为 §颜色码
val = val.replaceAll(
"&([0-9a-fk-orA-FK-OR])", "§$1");
return val;
}
private void createDefaultMessages(File f) {
List<String> L = new ArrayList<>();
L.add("# ==========================================");
L.add("# Sdf1_login 消息配置文件");
L.add("# ==========================================");
L.add("# 颜色代码使用 § 符号");
L.add("# §0黑色 §1深蓝 §2深绿 §3深青");
L.add("# §4深红 §5深紫 §6金色 §7灰色");
L.add("# §8深灰 §9蓝色 §a绿色 §b浅蓝");
L.add("# §c红色 §d粉色 §e黄色 §f白色");
L.add("# §l粗体 §n下划线 §o斜体 §r重置");
L.add("#");
L.add("# 占位符用 {xxx} 表示,运行时自动替换");
L.add("# 支持 <b>粗体</b> 标签");
L.add("# 支持 \\n 或 <br> 换行");
L.add("# 修改后执行 /sdf1_login reload 重载");
L.add("# ==========================================");
L.add("");
// --- 登录/注册 ---
L.add("# ---- 登录注册相关 ----");
L.add("# login_timeout: 登录超时被踢出时的提示");
L.add("login_timeout=§c登录超时,请重新登录");
L.add("# not_registered: 未注册玩家收到的提示");
L.add("not_registered=§e您尚未注册,请使用 /reg <密码>");
L.add("# not_logged_in: 已注册但未登录的提示");
L.add("not_logged_in=§e您尚未登录,请使用 /login <密码>");
L.add("# already_registered: 已注册玩家再次注册时的提示");
L.add("already_registered=§c您已注册过账号");
L.add("# already_logged_in: 已登录玩家再次登录时的提示");
L.add("already_logged_in=§c您已登录");
L.add("# reg_success: 注册成功提示");
L.add("reg_success=§a注册成功!欢迎来到服务器");
L.add("# login_success: 登录成功提示");
L.add("login_success=§a登录成功!");
L.add("# login_failed: 密码错误提示");
L.add("login_failed=§c密码错误,请重试");
L.add("");
// --- 密码 ---
L.add("# ---- 密码相关 ----");
L.add("# password_wrong: 旧密码验证失败");
L.add("password_wrong=§c密码错误");
L.add("# password_changed: 密码修改成功");
L.add("password_changed=§a密码修改成功!");
L.add("# password_format_error: 密码格式不符合要求");
L.add("password_format_error=§c密码需6位以上,含大小写字母和数字");
L.add("# password_same: 新密码与旧密码相同");
L.add("password_same=§c新密码不能与旧密码相同");
L.add("# reset_no_email: 找回密码时未绑定邮箱");
L.add("reset_no_email=§c您未绑定邮箱,无法找回密码");
L.add("# reset_sent: 临时密码已发送到邮箱");
L.add("reset_sent=§a临时密码已发送到您的邮箱,请查收");
L.add("");
// --- 签到积分 ---
L.add("# ---- 签到积分相关 ----");
L.add("# checkin_already: 今日已签到");
L.add("checkin_already=§c您今日已签到");
L.add("# checkin_success: 签到成功,{points}=获得积分 {streak}=连续天数 {multi}=倍率");
L.add("checkin_success=§a签到成功!获得 {points} 积分,连续 {streak} 天,倍率 x{multi}");
L.add("# points_insufficient: 积分不足");
L.add("points_insufficient=§c积分不足,当前: {points}");
L.add("# points_purchase_success: 积分购买成功");
L.add("points_purchase_success=§a购买成功!消耗 {count} 积分");
L.add("");
// --- 挂机 ---
L.add("# ---- 挂机检测相关 ----");
L.add("# afk_set_enabled: 挂机检测开启");
L.add("afk_set_enabled=§a挂机检测已开启");
L.add("# afk_set_disabled: 挂机检测关闭");
L.add("afk_set_disabled=§c挂机检测已关闭");
L.add("# afk_kick: 因挂机被踢出");
L.add("afk_kick=§c因挂机过久被踢出");
L.add("");
// --- 邮箱 ---
L.add("# ---- 邮箱相关 ----");
L.add("# email_set: 邮箱设置成功");
L.add("email_set=§a邮箱设置成功:{email}");
L.add("# email_format_error: 邮箱格式错误");
L.add("email_format_error=§c邮箱格式不正确");
L.add("");
// --- 邀请 ---
L.add("# ---- 邀请相关 ----");
L.add("# invite_code_generated: 邀请码生成");
L.add("invite_code_generated=§a邀请码:{code}");
L.add("# invite_used: 使用邀请码成功");
L.add("invite_used=§a邀请成功,邀请人:{player}");
L.add("# invite_self: 使用自己的邀请码");
L.add("invite_self=§c不能使用自己的邀请码");
L.add("# invite_not_found: 邀请码不存在");
L.add("invite_not_found=§c邀请码不存在");
L.add("# invite_referral_bonus: 被邀请人签到后邀请人获得奖励");
L.add("invite_referral_bonus=§a邀请奖励!获得 {points} 积分");
L.add("");
// --- 礼包 ---
L.add("# ---- 新人礼包相关 ----");
L.add("# gift_not_ready: 礼包条件未满足");
L.add("gift_not_ready=§c礼包条件未满足");
L.add("# gift_claimed: 礼包领取成功");
L.add("gift_claimed=§a礼包领取成功!");
L.add("");
// --- 审批/IP ---
L.add("# ---- 审批与IP限制 ----");
L.add("# need_approval: 注册需审批");
L.add("need_approval=§e注册需审批 (工单#{id})");
L.add("# ip_max_accounts: IP注册已达上限");
L.add("ip_max_accounts=§cIP注册已达上限");
L.add("");
// --- 管理员 ---
L.add("# ---- 管理员操作 ----");
L.add("# admin_delete_confirm: 管理员删除账号确认提示");
L.add("admin_delete_confirm=§c请在聊天中输入玩家名确认删除");
L.add("");
// --- 聊天过滤 ---
L.add("# ---- 聊天过滤 ----");
L.add("# chat_muted: 禁言玩家尝试聊天");
L.add("chat_muted=§c§l[Sdf1_chat] §f你已被禁言,无法聊天");
L.add("# chat_url_blocked: 发送违规链接被拦截");
L.add("# {url}=检测到的链接地址");
L.add("chat_url_blocked=§c§l[Sdf1_chat] §f检测到违规第三方链接: §e{url}");
L.add("# chat_url_violation: 当前违规次数");
L.add("# {count}=当前违规次数");
L.add("chat_url_violation=§7当前违规次数: §e{count}");
L.add("# chat_url_admin_notify: 通知管理员,{player}=玩家名 {url}=链接 {count}=次数");
L.add("chat_url_admin_notify=§e§l[Sdf1_chat] §f{player} 发送违规链接: {url} (第{count}次)");
L.add("# chat_url_broadcast: 全服通报,{player}=玩家名");
L.add("chat_url_broadcast=§c§l[Sdf1_chat] §e{player} §f因发送违规链接被处罚");
L.add("");
// --- 通用 ---
L.add("# ---- 通用 ----");
L.add("# 未知参数: 未知命令或权限不足时的提示");
L.add("未知参数=§c未知参数或权限不足");
L.add("# only_player: 仅玩家可用的命令");
L.add("# only_player=§c此命令仅玩家可用");
writeLines(f, L);
}
// ==================== SMTP ====================
public void loadSmtp() {
smtpSettings.clear();
File file = new File(dataFolder, "SMTP设置.txt");
if (!file.exists()) createDefaultSmtp(file);
loadMap(file, smtpSettings);
}
private double parseDouble(String s, double def) {
try { return Double.parseDouble(s.trim()); }
catch (Exception e) { return def; }
}
public String getSmtp(String key) {
return smtpSettings.getOrDefault(key, "");
}
/** 获取挂机白名单列表 */
public List<String> getAfkWhitelist() {
List<String> list = new ArrayList<>();
File file = new File(dataFolder, "挂机白名单.txt");
if (!file.exists()) {
try {
file.createNewFile();
try (BufferedWriter w = new BufferedWriter(
new OutputStreamWriter(
new FileOutputStream(file),
StandardCharsets.UTF_8))) {
w.write("# 每行一个玩家名,前面加 # 为注释");
w.newLine();
w.write("# 以下玩家不会被挂机踢出");
w.newLine();
}
} catch (IOException ignored) {}
}
try (BufferedReader r = new BufferedReader(
new InputStreamReader(
new FileInputStream(file),
StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null) {
line = line.trim();
if (!line.isEmpty() && !line.startsWith("#")) {
list.add(line);
}
}
} catch (IOException ignored) {}
return list;
}
/** 设置SMTP单项(聊天输入用) */
public void setSmtp(String key, String value) {
smtpSettings.put(key, value);
}
/** 将当前SMTP设置写回文件 */
public void saveSmtp() {
File file = new File(dataFolder, "SMTP设置.txt");
List<String> L = new ArrayList<>();
L.add("# ===== SMTP 邮件服务器配置 =====");
for (Map.Entry<String, String> e : smtpSettings.entrySet()) {
L.add(e.getKey() + "=" + e.getValue());
}
writeLines(file, L);
}
private void createDefaultSmtp(File f) {
List<String> L = new ArrayList<>();
L.add("# ===== SMTP 邮件服务器配置 =====");
L.add("smtp地址=smtp.example.com");
L.add("smtp端口=465");
L.add("smtp账号=your_email@example.com");
L.add("smtp密码=");
L.add("发件人名称=Sdf1_login");
L.add("smtp加密=true");
writeLines(f, L);
}
// ==================== 插件设置 ====================
public void loadSettings() {
File file = new File(dataFolder, "插件设置.txt");
if (!file.exists()) createDefaultSettings(file);
Map<String, String> m = new LinkedHashMap<>();
loadMap(file, m);
adminTag = m.getOrDefault("管理标签", adminTag);
adminPassword = m.getOrDefault("管理密码", adminPassword);
maxAccountsPerIP = parseInt(
m.getOrDefault("每IP最大账号数", "3"), 3);
approvalMode = m.getOrDefault("审批模式", approvalMode);
autoApproveDelayMinutes = parseInt(
m.getOrDefault("自动审批延迟分钟", "30"), 30);
loginTimeout = parseInt(
m.getOrDefault("登录超时秒数", "60"), 60);
afkEnabled = Boolean.parseBoolean(
m.getOrDefault("挂机踢出", "false"));
afkTimeout = parseInt(
m.getOrDefault("挂机超时秒数", "300"), 300);
smtpSsl = Boolean.parseBoolean(
m.getOrDefault("smtp加密", "true"));
providerPointsPerScore = parseDouble(
m.getOrDefault("服务商积分倍率", "2.0"),
2.0);
baseEconomyReward = parseInt(
m.getOrDefault("基础经济奖励", "100"),
100);
providerEconomyPerScore = parseDouble(
m.getOrDefault("经济奖励每评分", "50.0"),
50.0);
requesterPointsReward = parseInt(
m.getOrDefault("报单人积分奖励", "10"),
10);
garbageEnabled = Boolean.parseBoolean(
m.getOrDefault("垃圾站_启用", "true"));
garbageInterval = parseInt(
m.getOrDefault("垃圾站_清理间隔秒",
"300"), 300);
garbageMaxRounds = parseInt(
m.getOrDefault("垃圾站_保留轮数",
"1"), 1);
}
public void saveSettings() {
File file = new File(dataFolder, "插件设置.txt");
List<String> L = new ArrayList<>();
L.add("# ===== Sdf1_login 插件设置 =====");
L.add("管理标签=" + adminTag);
L.add("管理密码=" + adminPassword);
L.add("每IP最大账号数=" + maxAccountsPerIP);
L.add("审批模式=" + approvalMode);
L.add("自动审批延迟分钟=" + autoApproveDelayMinutes);
L.add("登录超时秒数=" + loginTimeout);
L.add("挂机踢出=" + afkEnabled);
L.add("挂机超时秒数=" + afkTimeout);
// 垃圾站设置
L.add("# ---- 垃圾站 ----");
L.add("垃圾站_启用=" + garbageEnabled);
L.add("垃圾站_清理间隔秒=" + garbageInterval);
L.add("垃圾站_保留轮数=" + garbageMaxRounds);
writeLines(file, L);
}
public double providerPointsPerScore = 2.0;
public int baseEconomyReward = 100;
public double providerEconomyPerScore = 50.0;
public int requesterPointsReward = 10;
public boolean garbageEnabled = true;
public int garbageInterval = 300;
public int garbageMaxRounds = 1;
private void createDefaultSettings(File f) {
List<String> L = new ArrayList<>();
L.add("# ===== Sdf1_login 插件设置 =====");
L.add("管理标签=admin");
L.add("管理密码=qweasd");
L.add("每IP最大账号数=3");
L.add("审批模式=manual");
L.add("自动审批延迟分钟=30");
L.add("登录超时秒数=60");
L.add("挂机踢出=false");
L.add("挂机超时秒数=300");
L.add("服务商积分倍率=" + providerPointsPerScore);
L.add("基础经济奖励=" + baseEconomyReward);
L.add("经济奖励每评分=" + providerEconomyPerScore);
L.add("报单人积分奖励=" + requesterPointsReward);
L.add("# ---- 垃圾站 ----");
L.add("垃圾站_启用=true");
L.add("垃圾站_清理间隔秒=300");
L.add("垃圾站_保留轮数=1");
writeLines(f, L);
}
// ==================== 通用读写 ====================
private void loadMap(File file, Map<String, String> map) {
try (BufferedReader r = new BufferedReader(
new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null) {
line = line.trim();
if (line.isEmpty() || line.startsWith("#")) continue;
int eq = line.indexOf('=');
if (eq > 0) map.put(line.substring(0, eq).trim(), line.substring(eq + 1).trim());
}
} catch (IOException e) {
System.err.println("[Sdf1_login] 读取失败: " + file.getName());
}
}
private void writeLines(File file, List<String> lines) {
try (BufferedWriter w = new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8))) {
for (String l : lines) { w.write(l); w.newLine(); }
} catch (IOException e) {
System.err.println("[Sdf1_login] 写入失败: " + file.getName());
}
}
private int parseInt(String s, int def) {
try { return Integer.parseInt(s.trim()); } catch (Exception e) { return def; }
}
/**
* 生成6位验证码(字母+数字)
*/
public String generateSmtpVerifyCode() {
String chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ "abcdefghijklmnopqrstuvwxyz"
+ "0123456789";
StringBuilder sb = new StringBuilder();
java.util.Random r = new java.util.Random();
for (int i = 0; i < 6; i++) {
sb.append(chars.charAt(r.nextInt(
chars.length())));
}
smtpVerifyCode = sb.toString();
smtpVerifyExpire =
System.currentTimeMillis() + 300000L;
return smtpVerifyCode;
}
/**
* 检查验证码是否有效
*/
public boolean checkSmtpVerifyCode(String code) {
if (System.currentTimeMillis() > smtpVerifyExpire) {
smtpVerifyCode = "";
return false;
}
return smtpVerifyCode.equalsIgnoreCase(code);
}
/**
* 清除验证码
*/
public void clearSmtpVerifyCode() {
smtpVerifyCode = "";
smtpVerifyExpire = 0;
}
/**
* 检查发送间隔是否>=1分钟
*/
public boolean canSendSmtpVerify() {
return System.currentTimeMillis()
- smtpLastSendTime >= 60000L;
}
public void recordSmtpSendTime() {
smtpLastSendTime = System.currentTimeMillis();
}
}
File diff suppressed because it is too large Load Diff
+275
View File
@@ -0,0 +1,275 @@
package Sdf1_login;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Base64;
import java.util.logging.Logger;
public class EmailManager {
private final ConfigManager config;
private final Logger logger;
public EmailManager(ConfigManager config) {
this.config = config;
this.logger = Logger.getLogger("Sdf1_login");
}
public boolean isConfigured() {
String host = config.getSmtp("smtp地址");
String user = config.getSmtp("smtp账号");
String pass = config.getSmtp("smtp密码");
return host != null && !host.isEmpty()
&& user != null && !user.isEmpty()
&& pass != null && !pass.isEmpty();
}
public boolean sendVerifyCode(String to,
String code) {
if (!isConfigured()) {
logger.warning(
"[Sdf1_login] SMTP未配置,"
+ "无法发送验证码");
return false;
}
logger.info("[Sdf1_login] 发送验证码到 " + to);
return sendEmailSync(to,
"[Sdf1_login] 验证码",
"您的验证码: " + code
+ "\n5分钟内有效。\n"
+ "如非本人操作请忽略。");
}
public boolean sendTempPassword(String to,
String player, String tempPwd) {
if (!isConfigured()) {
logger.warning(
"[Sdf1_login] SMTP未配置");
return false;
}
return sendEmailSync(to,
"[Sdf1_login] 临时密码",
"玩家 " + player
+ " 的临时密码: " + tempPwd
+ "\n请尽快登录并修改密码!");
}
private boolean sendEmailSync(String to,
String subject, String body) {
final boolean[] result = {false};
Thread t = new Thread(() -> {
try {
result[0] = sendEmail(
to, subject, body);
} catch (Exception e) {
logger.severe(
"[Sdf1_login] 邮件线程异常: "
+ e.getMessage());
}
}, "Sdf1_login-SMTP");
t.setDaemon(true);
t.start();
try {
t.join(15000);
} catch (InterruptedException ignored) {
}
if (t.isAlive()) {
t.interrupt();
logger.severe(
"[Sdf1_login] SMTP超时(15秒)");
return false;
}
return result[0];
}
private boolean sendEmail(String to,
String subject, String body) {
String host = config.getSmtp("smtp地址");
String portStr = config.getSmtp("smtp端口");
String user = config.getSmtp("smtp账号");
String pass = config.getSmtp("smtp密码");
String fromName = config.getSmtp(
"发件人名称");
int port;
try {
port = Integer.parseInt(portStr);
} catch (Exception e) {
port = 465;
}
try {
String encSubject = "=?UTF-8?B?"
+ Base64.getEncoder()
.encodeToString(
subject.getBytes("UTF-8"))
+ "?=";
String encBody = Base64.getEncoder()
.encodeToString(
body.getBytes("UTF-8"));
if (port == 465) {
SSLSocketFactory factory =
(SSLSocketFactory)
SSLSocketFactory
.getDefault();
SSLSocket socket = (SSLSocket)
factory.createSocket(
host, port);
socket.setSoTimeout(10000);
doSmtp(socket, host, user, pass,
to, encSubject, encBody,
fromName);
socket.close();
} else {
Socket plainSocket =
new Socket(host, port);
plainSocket.setSoTimeout(10000);
BufferedReader preReader =
new BufferedReader(
new InputStreamReader(
plainSocket
.getInputStream(),
"UTF-8"));
PrintWriter preWriter =
new PrintWriter(
plainSocket
.getOutputStream(),
true);
checkResponse(preReader, "220");
sendLine(preWriter,
"EHLO " + host);
checkResponse(preReader, "250");
sendLine(preWriter, "STARTTLS");
checkResponse(preReader, "220");
SSLSocketFactory factory =
(SSLSocketFactory)
SSLSocketFactory
.getDefault();
SSLSocket sslSocket = (SSLSocket)
factory.createSocket(
plainSocket, host,
port, true);
sslSocket.startHandshake();
doSmtp(sslSocket, host, user, pass,
to, encSubject, encBody,
fromName);
sslSocket.close();
}
logger.info(
"[Sdf1_login] 邮件发送成功 -> "
+ to);
return true;
} catch (Exception e) {
logger.severe(
"[Sdf1_login] 邮件异常: "
+ e.getClass()
.getSimpleName()
+ ": " + e.getMessage());
if (e.getCause() != null) {
logger.severe(
"[Sdf1_login] 根因: "
+ e.getCause()
.getMessage());
}
return false;
}
}
private void doSmtp(SSLSocket socket,
String host, String user, String pass,
String to, String subject, String body,
String fromName) throws Exception {
BufferedReader reader =
new BufferedReader(
new InputStreamReader(
socket.getInputStream(),
"UTF-8"));
OutputStream rawOut =
socket.getOutputStream();
PrintWriter writer =
new PrintWriter(
new java.io.BufferedOutputStream(
rawOut), true);
checkResponse(reader, "220");
sendLine(writer, "EHLO " + host);
checkResponse(reader, "250");
sendLine(writer, "AUTH LOGIN");
checkResponse(reader, "334");
sendLine(writer, Base64.getEncoder()
.encodeToString(
user.getBytes("UTF-8")));
checkResponse(reader, "334");
sendLine(writer, Base64.getEncoder()
.encodeToString(
pass.getBytes("UTF-8")));
checkResponse(reader, "235");
sendLine(writer,
"MAIL FROM:<" + user + ">");
checkResponse(reader, "250");
sendLine(writer,
"RCPT TO:<" + to + ">");
checkResponse(reader, "250");
sendLine(writer, "DATA");
checkResponse(reader, "354");
sendLine(writer,
"From: " + fromName
+ " <" + user + ">");
sendLine(writer,
"To: <" + to + ">");
sendLine(writer,
"Reply-To: <" + user + ">");
sendLine(writer,
"Subject: " + subject);
sendLine(writer, "MIME-Version: 1.0");
sendLine(writer,
"Content-Type: text/plain; "
+ "charset=UTF-8");
sendLine(writer,
"Content-Transfer-Encoding: "
+ "base64");
sendLine(writer, "");
sendLine(writer, body);
sendLine(writer, ".");
checkResponse(reader, "250");
sendLine(writer, "QUIT");
}
private void sendLine(PrintWriter writer,
String line) {
writer.print(line + "\r\n");
writer.flush();
}
private void checkResponse(
BufferedReader reader,
String expected) throws Exception {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
if (line.length() >= 4
&& Character.isDigit(
line.charAt(0))
&& line.charAt(3) == ' ')
break;
}
String resp = sb.toString().trim();
if (!resp.startsWith(expected)) {
if (resp.startsWith("535"))
logger.severe(
"[Sdf1_login] SMTP 535 "
+ "认证失败");
throw new Exception(
"SMTP响应异常: 期望 "
+ expected
+ ",实际: " + resp);
}
}
}
+602
View File
@@ -0,0 +1,602 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.*;
import org.bukkit.inventory.meta.ItemMeta;
import java.io.File;
import java.util.*;
public class GUIManager {
private final Main plugin;
public static final String T_MAIN =
"§6§l登录面板";
public static final String T_MY_INFO =
"§a§l我的信息";
public static final String T_ADMIN =
"§c§l管理员面板";
public static final String T_USER_MGMT =
"§e§l用户管理";
public static final String T_INVITE =
"§b§l邀请数据";
public static final String T_TASK_CENTER = "§d§l任务中心";
public static final String T_GIFT_STAGES = "§d§l新人任务";
public GUIManager(Main plugin) {
this.plugin = plugin;
}
public void openMain(Player p) {
Inventory g = Bukkit.createInventory(
null, 27, T_MAIN);
fillBg(g);
g.setItem(10, mkItem(Material.PLAYER_HEAD,
"§a§l我的信息"));
g.setItem(12, mkItem(Material.PAPER,
"§b§l邀请数据"));
g.setItem(14, mkItem(Material.EMERALD,
"§d§l积分商城"));
g.setItem(16, mkItem(Material.CHEST,
"§e§l新人礼包"));
g.setItem(18, mkItem(Material.BOOK,
"§6工单系统",
"§7提交bug、求助、举报"));
if (p.isOp() || isAdmin(p)) {
g.setItem(20, mkItem(Material.CHEST,
"§6垃圾回收站",
"§7查看和取回清理的物品"));
}
if (isAdmin(p)) {
g.setItem(22, mkItem(Material.REDSTONE_BLOCK,
"§c§l管理员面板"));
}
p.openInventory(g);
}
public void openMyInfo(Player p) {
Inventory g = Bukkit.createInventory(
null, 27, T_MY_INFO);
fillBg(g);
Map<String, Object> user = plugin.getDb()
.getUser(p.getName());
boolean checkedIn = plugin.getCheckIn()
.isCheckedInToday(p.getName());
Material checkMat = checkedIn
? Material.EMERALD : Material.COAL;
String checkName = checkedIn
? "§a已签到" : "§7点击签到";
int streak = ((Number) user.getOrDefault(
"checkin_streak", 0)).intValue();
int points = ((Number) user.getOrDefault(
"points", 0)).intValue();
int totalDays = ((Number) user.getOrDefault(
"total_checkin_days", 0)).intValue();
String email = (String) user.getOrDefault(
"email", "");
g.setItem(4, mkItem(Material.NAME_TAG,
"§e" + p.getName()));
g.setItem(10, mkItem(checkMat, checkName,
"§7连续签到: " + streak + "天",
"§7累计签到: " + totalDays + "天",
"§7当前积分: " + points));
g.setItem(11, mkItem(Material.PAPER,
"§e修改密码", "§7双击修改密码"));
g.setItem(12, mkItem(Material.CLOCK,
"§e补签", "§7消耗10积分补签3天内"));
g.setItem(13, mkItem(Material.ENDER_PEARL,
"§e邀请码",
"§7你的邀请码: "
+ user.getOrDefault(
"invite_code", "无"),
"§7点击生成/查看"));
g.setItem(14, mkItem(Material.BOOK,
"§e邮箱: "
+ (email.isEmpty()
? "未绑定" : email),
"§7点击绑定邮箱"));
g.setItem(15, mkItem(Material.EMERALD_BLOCK,
"§a§l积分商城",
"§7当前: " + points + "积分"));
g.setItem(26, mkItem(Material.ARROW, "§7返回"));
p.openInventory(g);
}
/**
* 打开账号删除选择面板
* 展示该玩家IP下所有账号,供选择删除
*/
public void openAccountDelete(Player p) {
String ip = plugin.getPlayerIP(p);
if (ip == null) {
p.sendMessage("§c无法获取你的IP地址");
return;
}
List<Map<String, Object>> accounts =
plugin.getDb().getAccountDetailsByIP(ip);
// 54格大箱子
org.bukkit.inventory.Inventory g =
org.bukkit.Bukkit.createInventory(
null, 54,
"§c§l选择要删除的账号");
// 填充玻璃面板
org.bukkit.inventory.ItemStack glass =
new org.bukkit.inventory.ItemStack(
org.bukkit.Material
.RED_STAINED_GLASS_PANE);
org.bukkit.inventory.meta.ItemMeta gm =
glass.getItemMeta();
if (gm != null) {
gm.setDisplayName(" ");
glass.setItemMeta(gm);
}
for (int i = 0; i < 54; i++) {
g.setItem(i, glass);
}
// 展示账号列表(从0号位开始放)
java.text.SimpleDateFormat sdf =
new java.text.SimpleDateFormat(
"yyyy-MM-dd HH:mm");
int slot = 0;
for (Map<String, Object> acc : accounts) {
if (slot >= 45) break;
String accName = (String) acc
.get("player_name");
long regTime = ((Number) acc
.get("register_time")).longValue();
String dateStr = regTime > 0
? sdf.format(
new java.util.Date(regTime))
: "未知";
// 当前登录的账号用不同颜色标记
boolean isCurrent =
accName.equalsIgnoreCase(
p.getName());
org.bukkit.inventory.ItemStack item =
new org.bukkit.inventory.ItemStack(
isCurrent
? org.bukkit.Material
.RED_WOOL
: org.bukkit.Material
.ORANGE_WOOL);
org.bukkit.inventory.meta.ItemMeta im =
item.getItemMeta();
if (im != null) {
im.setDisplayName(
(isCurrent ? "§c" : "§e")
+ accName);
im.setLore(java.util.Arrays.asList(
"§7注册时间: " + dateStr,
"§7IP: " + ip,
isCurrent
? "§c§l(当前账号)"
: "§a点击选择删除此账号"));
item.setItemMeta(im);
}
g.setItem(slot, item);
slot++;
}
// 49号位:返回
org.bukkit.inventory.ItemStack back =
new org.bukkit.inventory.ItemStack(
org.bukkit.Material.ARROW);
org.bukkit.inventory.meta.ItemMeta backMeta =
back.getItemMeta();
if (backMeta != null) {
backMeta.setDisplayName("§7返回");
back.setItemMeta(backMeta);
}
g.setItem(49, back);
p.openInventory(g);
}
/**
* 打开删除确认面板
*/
public void openDeleteConfirm(Player p,
String targetName) {
org.bukkit.inventory.Inventory g =
org.bukkit.Bukkit.createInventory(
null, 27,
"§c§l确认删除: " + targetName);
org.bukkit.inventory.ItemStack glass =
new org.bukkit.inventory.ItemStack(
org.bukkit.Material
.GRAY_STAINED_GLASS_PANE);
org.bukkit.inventory.meta.ItemMeta gm =
glass.getItemMeta();
if (gm != null) {
gm.setDisplayName(" ");
glass.setItemMeta(gm);
}
for (int i = 0; i < 27; i++) {
g.setItem(i, glass);
}
// 11号位:确认删除
org.bukkit.inventory.ItemStack confirm =
new org.bukkit.inventory.ItemStack(
org.bukkit.Material.LIME_WOOL);
org.bukkit.inventory.meta.ItemMeta cm =
confirm.getItemMeta();
if (cm != null) {
cm.setDisplayName("§a§l确认删除");
cm.setLore(java.util.Arrays.asList(
"§7删除账号: §c" + targetName,
"§c此操作不可撤销!"));
confirm.setItemMeta(cm);
}
g.setItem(11, confirm);
// 15号位:取消
org.bukkit.inventory.ItemStack cancel =
new org.bukkit.inventory.ItemStack(
org.bukkit.Material.RED_WOOL);
org.bukkit.inventory.meta.ItemMeta cml =
cancel.getItemMeta();
if (cml != null) {
cml.setDisplayName("§c§l取消");
cancel.setItemMeta(cml);
}
g.setItem(15, cancel);
p.openInventory(g);
}
public void openShopAdmin(Player p) {
Inventory g = Bukkit.createInventory(
null, 54, "§c§l商城管理");
ItemStack glass = new ItemStack(
Material.GRAY_STAINED_GLASS_PANE);
ItemMeta glm = glass.getItemMeta();
if (glm != null) {
glm.setDisplayName(" ");
glass.setItemMeta(glm);
}
for (int i = 0; i < 54; i++)
g.setItem(i, glass);
g.setItem(49, mkItem(Material.ARROW,
"§7返回商城"));
g.setItem(50, mkItem(Material.EMERALD,
"§a添加商品",
"§7格式: /sdf1_login shopadd <名称> <价格> <命令>"));
g.setItem(51, mkItem(Material.BARRIER,
"§c删除商品",
"§7格式: /sdf1_login shopdel <序号>"));
p.openInventory(g);
}
public void openAdmin(Player p) {
Inventory g = Bukkit.createInventory(
null, 27, T_ADMIN);
fillBg(g);
g.setItem(4, mkItem(Material.NAME_TAG,
"§e§l管理员面板"));
g.setItem(10, mkItem(Material.REDSTONE,
"§6SMTP配置",
"§7配置邮件服务器"));
g.setItem(11, mkItem(Material.ARMOR_STAND,
"§a用户管理",
"§7管理玩家数据"));
g.setItem(12, mkItem(Material.PLAYER_HEAD,
"§a§l我的信息"));
g.setItem(14, mkItem(Material.EMERALD,
"§d§l积分商城"));
boolean afkOn = plugin.getConfig2().afkEnabled;
int afkMin = plugin.getConfig2().afkTimeout / 60;
g.setItem(16, mkItem(
afkOn ? Material.REDSTONE_TORCH
: Material.TORCH,
afkOn ? "§a§l挂机踢出: 开启"
: "§c§l挂机踢出: 关闭",
"§7左键: 切换开关",
"§7右键: 设置时长",
"§7当前: " + afkMin + "分钟"));
g.setItem(22, mkItem(Material.ARROW, "§7返回"));
p.openInventory(g);
}
public void openInvite(Player p) {
Inventory g = Bukkit.createInventory(
null, 27, T_INVITE);
fillBg(g);
String code = (String) plugin.getDb()
.getField(p.getName(), "invite_code");
if (code == null || code.isEmpty()) {
code = plugin.getInvite().generateCode(p);
}
int monthly = ((Number) plugin.getDb()
.getField(p.getName(),
"monthly_invite_count"))
.intValue();
g.setItem(10, mkItem(Material.PAPER,
"§b§l我的邀请码",
"§7" + code,
"",
"§e点击刷新邀请码"));
g.setItem(12, mkItem(Material.PLAYER_HEAD,
"§a本月邀请",
"§7已邀请 §e" + monthly + " §7人"));
g.setItem(16, mkItem(Material.BOOK,
"§e输入邀请码",
"§7绑定他人的邀请码",
"",
"§e点击输入"));
g.setItem(26, mkItem(Material.ARROW, "§7返回"));
p.openInventory(g);
}
private String getInviteRate(int count) {
if (count > 15) return "60%";
if (count > 10) return "35%";
if (count > 5) return "15%";
if (count > 0) return "10%";
return "0%";
}
public void openUserManagement(Player p) {
Inventory g = Bukkit.createInventory(
null, 54, T_USER_MGMT);
fillBg(g);
List<Map<String, Object>> users = plugin.getDb()
.getAllUsers();
int slot = 0;
for (Map<String, Object> user : users) {
if (slot >= 45) break;
String name = (String) user.get("player_name");
int pts = ((Number) user.getOrDefault(
"points", 0)).intValue();
int stage = ((Number) user.getOrDefault(
"gift_stage", 0)).intValue();
g.setItem(slot, mkItem(Material.PLAYER_HEAD,
"§e" + name,
"§7积分: " + pts
+ " 礼包阶段: " + stage,
"§7双击管理"));
slot++;
}
g.setItem(53, mkItem(Material.ARROW, "§7返回"));
p.openInventory(g);
}
public void openTaskCenter(Player p) {
Inventory g = Bukkit.createInventory(
null, 27, T_TASK_CENTER);
fillBg(g);
g.setItem(10, mkItem(Material.BOOK,
"§e§l新人任务",
"§7完成新手阶段获取奖励"));
g.setItem(12, mkItem(Material.COMPASS,
"§b§l主线任务",
"§7查看主线任务列表"));
g.setItem(14, mkItem(Material.PAPER,
"§a§l支线任务",
"§7查看支线任务列表"));
g.setItem(16, mkItem(Material.EMERALD,
"§d§l每日签到",
"§7签到获取积分"));
g.setItem(22, mkItem(Material.ARROW, "§7返回"));
p.openInventory(g);
}
public void openGiftStages(Player p) {
Inventory g = Bukkit.createInventory(
null, 27, T_GIFT_STAGES);
fillBg(g);
int max = plugin.getGift().getMaxStage();
for (int i = 0; i < max && i < 9; i++) {
int stage = i + 1;
boolean claimed = plugin.getDb()
.getField(p.getName(),
"gift_claimed") != null
&& ((String) plugin.getDb()
.getField(p.getName(),
"gift_claimed"))
.contains("[" + stage + "]");
boolean can = plugin.getGift()
.canClaim(p, stage);
Material mat = claimed ? Material.LIME_WOOL
: (can ? Material.YELLOW_WOOL
: Material.RED_WOOL);
String status = claimed ? "§a已领取"
: (can ? "§e可领取" : "§c未达标");
g.setItem(10 + i, mkItem(mat,
"§e第" + stage + "阶段",
"§7" + status));
}
g.setItem(26, mkItem(Material.ARROW, "§7返回"));
p.openInventory(g);
}
public void openTaskList(Player p, String folderName) {
File folder = new File(
plugin.getDataFolder(), folderName);
List<Map<String, Object>> tasks =
plugin.getGift()
.loadTasksFromFolder(folder);
int size = Math.max(54,
((tasks.size() / 9) + 1) * 9);
if (size > 54) size = 54;
Inventory g = Bukkit.createInventory(null, size,
"§d§l" + folderName);
fillBg(g);
for (int i = 0; i < tasks.size()
&& i < 45; i++) {
Map<String, Object> task = tasks.get(i);
String name = (String) task.get("name");
List<String> conds =
(List<String>) task.get("conditions");
List<String> rewards =
(List<String>) task.get("rewards");
List<String> lore = new ArrayList<>();
lore.add("§7───── 条件 ─────");
if (conds != null)
for (String c : conds)
lore.add("§e" + c);
lore.add("");
lore.add("§7───── 奖励 ─────");
if (rewards != null)
for (String r2 : rewards)
lore.add("§a" + r2);
g.setItem(i, mkItem(Material.BOOK,
"§e" + name,
lore.toArray(new String[0])));
}
if (tasks.isEmpty()) {
g.setItem(22, mkItem(Material.BARRIER,
"§7暂无任务"));
}
g.setItem(size - 1,
mkItem(Material.ARROW, "§7返回"));
p.openInventory(g);
}
public void openUserDetail(Player p, String target) {
Inventory g = Bukkit.createInventory(
null, 27, "§e§l管理: " + target);
fillBg(g);
Map<String, Object> user = plugin.getDb()
.getUser(target);
int pts = ((Number) user.getOrDefault(
"points", 0)).intValue();
String email = (String) user.getOrDefault(
"email", "");
g.setItem(4, mkItem(Material.NAME_TAG,
"§e" + target));
g.setItem(10, mkItem(Material.EMERALD,
"§a积分: " + pts,
"§7点击设置积分"));
g.setItem(11, mkItem(Material.PAPER,
"§e邮箱: "
+ (email.isEmpty() ? "无" : email)));
g.setItem(12, mkItem(Material.BOOK,
"§e设置密码"));
g.setItem(16, mkItem(Material.BARRIER,
"§c删除用户", "§7二次验证删除"));
// 雪球菜单检测(slot 14)
g.setItem(14, mkItem(
Material.SNOWBALL,
"§e§l雪球菜单",
"§7点击为该玩家发放菜单雪球"));
g.setItem(22, mkItem(Material.ARROW, "§7返回"));
p.openInventory(g);
}
/**
* 打开账号请求面板
* 玩家通过 /oa 命令触发
*/
public void openAccountRequest(Player p) {
org.bukkit.inventory.Inventory g =
org.bukkit.Bukkit.createInventory(
null, 27,
"§e§l账号请求");
// 填充玻璃面板
org.bukkit.inventory.ItemStack glass =
new org.bukkit.inventory.ItemStack(
org.bukkit.Material
.YELLOW_STAINED_GLASS_PANE);
org.bukkit.inventory.meta.ItemMeta gm =
glass.getItemMeta();
if (gm != null) {
gm.setDisplayName(" ");
glass.setItemMeta(gm);
}
for (int i = 0; i < 27; i++) {
g.setItem(i, glass);
}
// 10号位:申请删除账号
g.setItem(10, makeItem(
org.bukkit.Material.BARRIER,
"§c§l申请删除账号",
"§7点击后确认删除您的账号",
"§7此操作不可撤销"));
// 13号位:找回密码
g.setItem(13, makeItem(
org.bukkit.Material.TRIPWIRE_HOOK,
"§e§l找回密码",
"§7通过绑定邮箱获取临时密码",
"§7需要先绑定邮箱"));
// 16号位:联系管理员
g.setItem(16, makeItem(
org.bukkit.Material.EMERALD,
"§a§l联系管理员",
"§7遇到问题?向管理员提交工单"));
// 22号位:返回主菜单
g.setItem(22, makeItem(
org.bukkit.Material.ARROW,
"§7返回"));
p.openInventory(g);
}
/**
* 创建物品的工具方法
*/
private org.bukkit.inventory.ItemStack makeItem(
org.bukkit.Material mat,
String displayName,
String... lore) {
org.bukkit.inventory.ItemStack item =
new org.bukkit.inventory.ItemStack(mat);
org.bukkit.inventory.meta.ItemMeta meta =
item.getItemMeta();
if (meta != null) {
meta.setDisplayName(displayName);
if (lore.length > 0) {
meta.setLore(
java.util.Arrays.asList(lore));
}
item.setItemMeta(meta);
}
return item;
}
private boolean isAdmin(Player p) {
return p.getScoreboardTags().contains(
plugin.getConfig2().adminTag);
}
private void fillBg(Inventory inv) {
ItemStack gl = mkItem(
Material.GRAY_STAINED_GLASS_PANE, " ");
for (int i = 0; i < inv.getSize(); i++)
inv.setItem(i, gl);
}
private ItemStack mkItem(Material mat, String name,
String... lore) {
ItemStack it = new ItemStack(mat);
ItemMeta im = it.getItemMeta();
if (im != null) {
im.setDisplayName(name);
if (lore.length > 0)
im.setLore(Arrays.asList(lore));
it.setItemMeta(im);
}
return it;
}
}
@@ -0,0 +1,610 @@
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) {
try {
byte[] bytes =
stack.serializeAsBytes();
String b64 = java.util.Base64
.getEncoder()
.encodeToString(bytes);
int len = b64.length();
String f = len > 15
? b64.substring(10, 15) : "短";
String m = len >= 5
? b64.substring(len / 2,
Math.min(len / 2 + 5, len))
: "短";
String b = len > 10
? b64.substring(len - 10, len - 5)
: "短";
plugin.getLogger().info(
"[垃圾站加载] ID#" + id
+ " " + stack.getType()
+ " x" + amount
+ " base64[" + len + "]"
+ " 前=" + f
+ " 中=" + m
+ " 后=" + b);
} catch (Exception ex) {
plugin.getLogger().warning(
"[垃圾站加载] 序列化异常: "
+ ex.getMessage());
}
}
if (stack != null) {
stack.setAmount(amount);
ItemMeta im =
stack.getItemMeta();
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++;
}
// 45-48 格:额外投放区(空位)
// 52-53 格:额外投放区(空位)
// 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 = total >= pageSize
&& 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) {
}
}
}
+259
View File
@@ -0,0 +1,259 @@
package Sdf1_login;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.PrintWriter;
public class GiftManager {
private final File giftFolder;
private final Main plugin;
private final List<Map<String, Object>> stages =
new ArrayList<>();
public GiftManager(Main plugin) {
this.plugin = plugin;
this.giftFolder = new File(
plugin.getDataFolder(), "新人礼包");
}
public void loadStages() {
stages.clear();
if (!giftFolder.exists()) {
giftFolder.mkdirs();
writeDefaultStages();
}
File[] files = giftFolder.listFiles(
(dir, name) -> name.endsWith(".md"));
if (files == null) return;
Arrays.sort(files);
for (File f : files) {
stages.add(parseMarkdown(f));
}
}
private Map<String, Object> parseMarkdown(File f) {
Map<String, Object> stage = new LinkedHashMap<>();
stage.put("stage", stages.size() + 1);
stage.put("name",
f.getName().replace(".md", ""));
stage.put("rewards", new ArrayList<String>());
try {
BufferedReader r = new BufferedReader(
new InputStreamReader(
new FileInputStream(f),
StandardCharsets.UTF_8));
String line;
List<String> rewards = new ArrayList<>();
while ((line = r.readLine()) != null) {
if (line.contains("礼包内容")
|| line.contains("奖励")
|| line.contains("任务内容")) {
int colon = line.indexOf(':');
if (colon < 0)
colon = line.indexOf('\uff1a');
if (colon >= 0) {
rewards.add(
line.substring(colon + 1)
.trim());
}
}
}
stage.put("rewards", rewards);
r.close();
} catch (Exception e) {
e.printStackTrace();
}
return stage;
}
private void writeStage(String name, String content) {
File f = new File(giftFolder, name);
if (f.exists()) return;
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets.UTF_8))) {
pw.print(content);
} catch (IOException ignored) {}
}
private void writeDefaultStages() {
writeStage("第一阶段.md",
"# 第一阶段\n\n## 领取条件\n\n- 无前级条件\n\n## 礼包内容\n\n- 皮革套装 × 1\n- 木制武器工具套装 × 1\n");
writeStage("第二阶段.md",
"# 第二阶段\n\n## 领取条件\n\n- 在线时长>1小时\n\n## 礼包内容\n\n- 500 游戏币\n");
writeStage("第三阶段.md",
"# 第三阶段\n\n## 领取条件\n\n- 破坏100个方块\n- 在线>1天\n\n## 礼包内容\n\n- 1500 游戏币\n- 铁质工具套装 × 1\n- 铁质防具 × 1\n");
writeStage("第四阶段.md",
"# 第四阶段\n\n## 领取条件\n\n- 破坏/放置300方块\n- 在线≥7天\n\n## 礼包内容\n\n- 钻石套装 × 1\n- 2000 游戏币\n");
}
/** 从任意文件夹加载任务列表 */
public List<Map<String, Object>> loadTasksFromFolder(
File folder) {
List<Map<String, Object>> tasks =
new ArrayList<>();
if (!folder.exists() || !folder.isDirectory())
return tasks;
File[] files = folder.listFiles(
(dir, name) -> name.endsWith(".md"));
if (files == null) return tasks;
Arrays.sort(files);
for (File f : files) {
Map<String, Object> task =
new LinkedHashMap<>();
task.put("name",
f.getName().replace(".md", ""));
task.put("conditions",
new ArrayList<String>());
task.put("rewards",
new ArrayList<String>());
try {
BufferedReader r = new BufferedReader(
new InputStreamReader(
new FileInputStream(f),
StandardCharsets.UTF_8));
String line;
List<String> current = null;
while ((line = r.readLine()) != null) {
if (line.startsWith("# ")) {
task.put("name",
line.substring(2)
.trim());
} else if (line.contains("条件")
|| line.contains("领取条件")
|| line.contains("完成条件")) {
current = (List<String>) task
.get("conditions");
} else if (line.contains("内容")
|| line.contains("奖励")
|| line.contains("任务内容")
|| line.contains("礼包内容")) {
current = (List<String>) task
.get("rewards");
} else if (line.startsWith("- ")
&& current != null) {
current.add(line.substring(2)
.trim());
}
}
r.close();
} catch (Exception e) {
e.printStackTrace();
}
tasks.add(task);
}
return tasks;
}
public boolean canClaim(Player p, int stage) {
String name = p.getName();
int curStage = ((Number) plugin.getDb()
.getField(name, "gift_stage"))
.intValue();
String claimed = (String) plugin.getDb()
.getField(name, "gift_claimed");
if (claimed == null) claimed = "";
return curStage >= stage
&& !claimed.contains(
String.valueOf(stage));
}
public void claimReward(Player p, int stage) {
if (!canClaim(p, stage)) return;
Map<String, Object> user = plugin.getDb()
.getUser(p.getName());
String claimed = (String) user.getOrDefault(
"gift_claimed", "");
claimed += "[" + stage + "]";
plugin.getDb().setField(
p.getName(), "gift_claimed", claimed);
plugin.getDb().setField(
p.getName(), "gift_stage",
Math.max(stage,
((Number) user.getOrDefault(
"gift_stage", 0))
.intValue()));
if (stage == 1) {
giveItem(p, Material.LEATHER_HELMET);
giveItem(p, Material.LEATHER_CHESTPLATE);
giveItem(p, Material.LEATHER_LEGGINGS);
giveItem(p, Material.LEATHER_BOOTS);
giveItem(p, Material.WOODEN_SWORD);
giveItem(p, Material.WOODEN_PICKAXE);
giveItem(p, Material.WOODEN_AXE);
giveItem(p, Material.WOODEN_SHOVEL);
} else if (stage == 2) {
if (plugin.getEconomy() != null) {
double before = plugin.getEconomy()
.getBalance(p);
plugin.getEconomy()
.depositPlayer(p, 500);
double after = plugin.getEconomy()
.getBalance(p);
p.sendMessage("§a§l[礼包] §f游戏币: §e"
+ (long) before
+ "§f → §a" + (long) after);
}
} else if (stage == 3) {
if (plugin.getEconomy() != null) {
double before = plugin.getEconomy()
.getBalance(p);
plugin.getEconomy()
.depositPlayer(p, 1500);
double after = plugin.getEconomy()
.getBalance(p);
p.sendMessage("§a§l[礼包] §f游戏币: §e"
+ (long) before
+ "§f → §a" + (long) after);
}
giveItem(p, Material.IRON_PICKAXE);
giveItem(p, Material.IRON_AXE);
giveItem(p, Material.IRON_SWORD);
giveItem(p, Material.IRON_HELMET);
giveItem(p, Material.IRON_CHESTPLATE);
giveItem(p, Material.IRON_LEGGINGS);
giveItem(p, Material.IRON_BOOTS);
} else if (stage == 4) {
if (plugin.getEconomy() != null) {
double before = plugin.getEconomy()
.getBalance(p);
plugin.getEconomy()
.depositPlayer(p, 2000);
double after = plugin.getEconomy()
.getBalance(p);
p.sendMessage("§a§l[礼包] §f游戏币: §e"
+ (long) before
+ "§f → §a" + (long) after);
}
giveItem(p, Material.DIAMOND_HELMET);
giveItem(p, Material.DIAMOND_CHESTPLATE);
giveItem(p, Material.DIAMOND_LEGGINGS);
giveItem(p, Material.DIAMOND_BOOTS);
}
p.sendMessage(plugin.getConfig2().msg(
"gift_claimed"));
}
private void giveItem(Player p, Material mat) {
p.getInventory().addItem(new ItemStack(mat));
}
public List<Map<String, Object>> getStages() {
return stages;
}
public int getMaxStage() {
return stages.size();
}
}
@@ -0,0 +1,67 @@
package Sdf1_login;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
public class IPGroupManager {
private final Main plugin;
private final int maxAccounts;
public IPGroupManager(Main plugin, int maxAccounts) {
this.plugin = plugin;
this.maxAccounts = maxAccounts;
}
/**
* 检查该IP是否还能注册新账号
*/
public boolean canRegister(String ip) {
if (maxAccounts <= 0) return true;
if (ip == null || ip.isEmpty()) return true;
return queryAccountsByIP(ip).size() < maxAccounts;
}
/**
* 获取该IP下已注册的账号数量
*/
public int getAccountCount(String ip) {
if (ip == null || ip.isEmpty()) return 0;
return queryAccountsByIP(ip).size();
}
/**
* 获取该IP下所有账号名
*/
public List<String> getAccounts(String ip) {
return queryAccountsByIP(ip);
}
/**
* 直接通过数据库连接查询指定IP下的所有账号
*/
private List<String> queryAccountsByIP(String ip) {
List<String> accounts = new ArrayList<>();
Connection conn = plugin.getDb().getDb();
if (conn == null) return accounts;
try {
PreparedStatement ps = conn.prepareStatement(
"SELECT player_name FROM users "
+ "WHERE ip_address = ?");
ps.setString(1, ip);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
accounts.add(
rs.getString("player_name"));
}
rs.close();
ps.close();
} catch (Exception e) {
e.printStackTrace();
}
return accounts;
}
}
@@ -0,0 +1,46 @@
package Sdf1_login;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.io.BukkitObjectInputStream;
import org.bukkit.util.io.BukkitObjectOutputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;
public class InventorySerializer {
public static String toBase64(ItemStack[] items) throws IOException {
try {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
BukkitObjectOutputStream dataOutput = new BukkitObjectOutputStream(outputStream);
dataOutput.writeInt(items.length);
for (ItemStack item : items) {
dataOutput.writeObject(item);
}
dataOutput.close();
return Base64.getEncoder().encodeToString(outputStream.toByteArray());
} catch (Exception e) {
throw new IOException("Unable to serialize item stacks.", e);
}
}
public static ItemStack[] fromBase64(String data) throws IOException {
if (data == null || data.isEmpty()) {
return new ItemStack[0];
}
try {
ByteArrayInputStream inputStream = new ByteArrayInputStream(Base64.getDecoder().decode(data));
BukkitObjectInputStream dataInput = new BukkitObjectInputStream(inputStream);
ItemStack[] items = new ItemStack[dataInput.readInt()];
for (int i = 0; i < items.length; i++) {
items[i] = (ItemStack) dataInput.readObject();
}
dataInput.close();
return items;
} catch (ClassNotFoundException e) {
throw new IOException("Unable to deserialize item stacks.", e);
}
}
}
@@ -0,0 +1,96 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.text.SimpleDateFormat;
import java.util.*;
public class InviteManager {
private final Main plugin;
public InviteManager(Main plugin) {
this.plugin = plugin;
}
public String generateCode(Player p) {
String code = PasswordUtils.generateInviteCode();
plugin.getDb().setField(
p.getName(), "invite_code", code);
return code;
}
public void onInviteeCheckIn(String inviteeName) {
String invitedBy = (String) plugin.getDb()
.getField(inviteeName, "invited_by");
if (invitedBy == null || invitedBy.isEmpty())
return;
String month = new SimpleDateFormat("yyyy-MM")
.format(new Date());
String lastMonth = (String) plugin.getDb()
.getField(invitedBy, "invite_month");
if (!month.equals(lastMonth)) {
plugin.getDb().setField(
invitedBy, "invite_month", month);
plugin.getDb().setField(
invitedBy, "monthly_invite_count", 0);
}
int count = ((Number) plugin.getDb()
.getField(invitedBy,
"monthly_invite_count"))
.intValue();
plugin.getDb().setField(
invitedBy, "monthly_invite_count",
count + 1);
double rate = 0.10;
if (count + 1 > 15) {
rate = 0.60;
} else if (count + 1 > 10) {
rate = 0.35;
} else if (count + 1 > 5) {
rate = 0.15;
}
int bonus = (int) (1 * rate);
if (bonus > 0) {
plugin.getDb().addPoints(invitedBy, bonus);
Player inviterPlayer = Bukkit
.getPlayerExact(invitedBy);
if (inviterPlayer != null
&& inviterPlayer.isOnline()) {
inviterPlayer.sendMessage(
plugin.getConfig2()
.msg("invite_referral_bonus")
.replace("{points}",
String.valueOf(bonus)));
}
}
}
public List<Map<String, Object>> getAllInviteData() {
List<Map<String, Object>> result =
new ArrayList<>();
for (Map<String, Object> user
: plugin.getDb().getAllUsers()) {
String code = (String) user.getOrDefault(
"invite_code", "");
if (!code.isEmpty()) {
Map<String, Object> row =
new LinkedHashMap<>();
row.put("player",
user.get("player_name"));
row.put("code", code);
row.put("monthly",
user.getOrDefault(
"monthly_invite_count", 0));
result.add(row);
}
}
return result;
}
}
+302
View File
@@ -0,0 +1,302 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
public class LoginManager {
private final Main plugin;
public LoginManager(Main plugin) {
this.plugin = plugin;
}
public boolean handleRegister(Player p,
String[] args) {
String name = p.getName();
// 已注册玩家不能再注册
if (plugin.getDb().userExists(name)) {
p.sendMessage(plugin.getConfig2()
.msg("already_registered"));
return true;
}
// IP注册限制检查
String ip = plugin.getPlayerIP(p);
if (ip != null && plugin.getConfig2()
.maxAccountsPerIP > 0) {
if (!plugin.getIPGroup()
.canRegister(ip)) {
int count = plugin.getIPGroup()
.getAccountCount(ip);
int max = plugin.getConfig2()
.maxAccountsPerIP;
p.sendMessage("§c该IP注册已达上限("
+ count + "/" + max + ")");
return true;
}
}
if (args.length < 1) {
p.sendMessage("§c用法: /reg <密码>");
p.sendMessage("§7密码要求: 6位以上,"
+ "包含大小写字母和数字");
return true;
}
String pwd = args[0];
if (pwd.length() < 6) {
p.sendMessage("§c密码长度不足6位");
return true;
}
if (!pwd.matches(".*[a-z].*")
|| !pwd.matches(".*[A-Z].*")
|| !pwd.matches(".*[0-9].*")) {
p.sendMessage("§c密码必须包含大写字母、"
+ "小写字母和数字");
return true;
}
String salt =
PasswordUtils.generateSalt();
String hash =
PasswordUtils.hash(pwd, salt);
plugin.getDb().createUser(
name, hash, salt);
plugin.getLoggedIn().add(name);
plugin.getDb().setLoggedIn(name, true);
plugin.getDb().setField(name,
"last_login_time",
System.currentTimeMillis());
plugin.getDb().setField(name,
"last_online_check",
System.currentTimeMillis());
plugin.getDb().recordIP(name, ip);
plugin.getDb().setField(name,
"register_time",
System.currentTimeMillis());
plugin.restoreInventory(p);
plugin.recordIPLogin(p);
plugin.giveMenuSnowball(p);
p.sendMessage(plugin.getConfig2()
.msg("reg_success"));
playRegisterSound(p);
try {
org.bukkit.plugin.Plugin cy =
Bukkit.getPluginManager()
.getPlugin("CY_beibao");
if (cy != null && cy.isEnabled()) {
cy.getClass().getMethod(
"onSdf1Activation",
String.class,
int.class, int.class)
.invoke(cy, name, 9, 0);
}
} catch (Exception ignored) {
}
return true;
}
private void playRegisterSound(Player p) {
p.playSound(p.getLocation(),
Sound.BLOCK_NOTE_BLOCK_CHIME,
1.0f, 0.79f);
p.playSound(p.getLocation(),
Sound.BLOCK_NOTE_BLOCK_BASS,
1.0f, 0.79f);
p.playSound(p.getLocation(),
Sound.BLOCK_NOTE_BLOCK_PLING,
1.0f, 0.79f);
}
public boolean handleLogin(Player p,
String[] args) {
String name = p.getName();
try {
if (plugin.getLoggedIn().contains(name)) {
p.sendMessage(plugin.getConfig2()
.msg("already_logged_in"));
return true;
}
if (!plugin.getDb().userExists(name)) {
p.sendMessage(plugin.getConfig2()
.msg("not_registered"));
return true;
}
if (args.length < 1) {
p.sendMessage("§c用法: /login <密码>");
return true;
}
String pwd = args[0];
String salt = (String) plugin.getDb()
.getField(name, "password_salt");
if (salt == null) {
p.sendMessage(
"§c数据异常,请联系管理员");
plugin.getLogger().severe(
"[Sdf1_login] 玩家 " + name
+ " 盐值为null");
return true;
}
String hash =
PasswordUtils.hash(pwd, salt);
boolean matchMain =
plugin.getDb().checkPassword(
name, hash);
if (matchMain) {
plugin.getLoggedIn().add(name);
plugin.getDb().setLoggedIn(
name, true);
plugin.getDb().setField(name,
"last_login_time",
System.currentTimeMillis());
plugin.getDb().setField(name,
"last_online_check",
System.currentTimeMillis());
// 只在有有效备份时恢复
plugin.restoreInventory(p);
plugin.recordIPLogin(p);
plugin.giveMenuSnowball(p);
p.sendMessage(plugin.getConfig2()
.msg("login_success"));
activateCY(p, name);
return true;
}
boolean matchTemp =
plugin.getDb()
.checkPasswordOrTemp(
name, hash);
if (matchTemp) {
plugin.getLoggedIn().add(name);
plugin.getDb().setLoggedIn(
name, true);
plugin.getDb().setField(name,
"last_login_time",
System.currentTimeMillis());
plugin.getDb().setField(name,
"last_online_check",
System.currentTimeMillis());
plugin.restoreInventory(p);
plugin.recordIPLogin(p);
plugin.giveMenuSnowball(p);
plugin.getNeedsPasswordChange()
.add(name);
p.sendMessage(plugin.getConfig2()
.msg("login_success"));
p.sendMessage(
"§c§l[警告] §f您使用的是临时密码,请尽快修改密码!");
p.sendMessage(
"§7用法: /sdf1_login pw");
activateCY(p, name);
return true;
}
p.sendMessage(plugin.getConfig2()
.msg("login_failed"));
} catch (Exception e) {
plugin.getLogger().severe(
"[Sdf1_login] handleLogin异常: "
+ e.getMessage());
e.printStackTrace();
p.sendMessage(
"§c登录过程出错,请联系管理员");
}
return true;
}
public void handleReset(Player p) {
String name = p.getName();
String emailAddr = (String)
plugin.getDb().getField(
name, "email");
if (emailAddr == null
|| emailAddr.isEmpty()) {
p.sendMessage(
"§c未设置邮箱,无法重置密码");
return;
}
String tempPwd = PasswordUtils
.generateTempPassword();
String salt = (String)
plugin.getDb().getField(
name, "password_salt");
String hash = PasswordUtils.hash(
tempPwd, salt);
plugin.getDb().setField(name,
"temp_password", hash);
plugin.getDb().setField(name,
"temp_pw_expire",
System.currentTimeMillis()
+ 300000L);
plugin.getDb().setField(name,
"temp_pw_used", 0);
p.sendMessage("§e正在发送临时密码...");
final String to = emailAddr;
final String fName = name;
final String pwd = tempPwd;
final Main self = plugin;
Bukkit.getScheduler()
.runTaskAsynchronously(self,
() -> {
final boolean[] sent = {false};
Thread t = new Thread(() -> {
sent[0] = self.getEmail()
.sendTempPassword(
to, fName, pwd);
}, "Sdf1_login-reset");
t.setDaemon(true);
t.start();
try {
t.join(15000);
} catch (
InterruptedException ignored) {
}
if (t.isAlive()) {
t.interrupt();
Bukkit.getScheduler().runTask(
self, () ->
p.sendMessage(
"§c邮件发送超时"));
return;
}
Bukkit.getScheduler().runTask(
self, () -> {
if (sent[0]) {
p.sendMessage(
"§a临时密码已发送到 "
+ to);
} else {
p.sendMessage(
"§c邮件发送失败");
}
});
});
}
private void activateCY(Player p,
String name) {
try {
org.bukkit.plugin.Plugin cy =
Bukkit.getPluginManager()
.getPlugin("CY_beibao");
if (cy != null && cy.isEnabled()) {
cy.getClass().getMethod(
"onSdf1Activation",
String.class,
int.class, int.class)
.invoke(cy, name, 0, 0);
}
} catch (Exception ignored) {
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
package Sdf1_login;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
public class PasswordUtils {
private static final SecureRandom RANDOM =
new SecureRandom();
public static String generateSalt() {
byte[] salt = new byte[16];
RANDOM.nextBytes(salt);
return Base64.getEncoder().encodeToString(salt);
}
public static String hash(String password, String salt) {
try {
MessageDigest md =
MessageDigest.getInstance("SHA-256");
md.update(Base64.getDecoder().decode(salt));
byte[] hash =
md.digest(password.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(hash);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static boolean validate(String password) {
if (password == null) return false;
if (password.length() < 6 || password.length() > 20)
return false;
boolean hasUpper = false;
boolean hasLower = false;
boolean hasDigit = false;
for (char c : password.toCharArray()) {
if (Character.isUpperCase(c)) hasUpper = true;
if (Character.isLowerCase(c)) hasLower = true;
if (Character.isDigit(c)) hasDigit = true;
}
return hasUpper && hasLower && hasDigit;
}
public static String generateTempPassword() {
String chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
StringBuilder sb = new StringBuilder(8);
for (int i = 0; i < 8; i++) {
sb.append(chars.charAt(
RANDOM.nextInt(chars.length())));
}
return sb.toString();
}
public static String generateInviteCode() {
String chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
StringBuilder sb = new StringBuilder(6);
for (int i = 0; i < 6; i++) {
sb.append(chars.charAt(
RANDOM.nextInt(chars.length())));
}
return sb.toString();
}
public static String generateVerifyCode() {
int code = 100000 + RANDOM.nextInt(900000);
return String.valueOf(code);
}
}
+130
View File
@@ -0,0 +1,130 @@
package Sdf1_login;
import java.io.File;
import java.sql.*;
import java.util.logging.Logger;
public class PlayerStorage {
private Connection conn;
private final Logger logger;
public PlayerStorage(File dataFolder,
Logger logger) {
this.logger = logger;
File dbFile = new File(
dataFolder,
"quest_data.db");
try {
Class.forName(
"org.sqlite.JDBC");
conn = DriverManager
.getConnection(
"jdbc:sqlite:"
+ dbFile
.getAbsolutePath());
conn.setAutoCommit(true);
Statement s =
conn.createStatement();
s.executeUpdate(
"CREATE TABLE IF NOT EXISTS "
+ "quest_kv ("
+ "player TEXT NOT NULL,"
+ "key TEXT NOT NULL,"
+ "value TEXT,"
+ "PRIMARY KEY"
+ "(player, key))");
s.close();
logger.info(
"[QuestStorage] 初始化: "
+ dbFile.getAbsolutePath());
} catch (Exception e) {
logger.severe(
"[QuestStorage] 失败: "
+ e.getMessage());
}
}
public Object getCondition(
String player, String key) {
if (conn == null) return null;
try {
PreparedStatement ps =
conn.prepareStatement(
"SELECT value "
+ "FROM quest_kv "
+ "WHERE player=? "
+ "AND key=?");
ps.setString(1,
player.toLowerCase());
ps.setString(2, key);
ResultSet rs =
ps.executeQuery();
String val = rs.next()
? rs.getString("value")
: null;
rs.close();
ps.close();
return parseValue(val);
} catch (SQLException e) {
return null;
}
}
public void setCondition(
String player, String key) {
setConditionRaw(
player, key, "true");
}
public void debugPlayer(String player) {
}
public void setConditionRaw(
String player, String key,
String value) {
if (conn == null) return;
try {
PreparedStatement ps =
conn.prepareStatement(
"INSERT OR REPLACE "
+ "INTO quest_kv"
+ "(player,key,value)"
+ " VALUES(?,?,?)");
ps.setString(1,
player.toLowerCase());
ps.setString(2, key);
ps.setString(3, value);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
logger.warning(
"[QuestStorage] 写入失败: "
+ key + "=" + value
+ " | " + e.getMessage());
}
}
public void close() {
try {
if (conn != null
&& !conn.isClosed())
conn.close();
} catch (Exception e) {}
}
private Object parseValue(String v) {
if (v == null) return null;
try {
return Long.parseLong(v);
} catch (Exception e1) {
try {
return Double.parseDouble(v);
} catch (Exception e2) {
if ("true".equalsIgnoreCase(v))
return true;
if ("false".equalsIgnoreCase(v))
return false;
return v;
}
}
}
}
+374
View File
@@ -0,0 +1,374 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.List;
import java.lang.reflect.Method;
import java.util.*;
public class PointsManager {
private final Main plugin;
private Object cyInstance = null;
private Method cyActivationMethod = null;
private Method cyGetShopItemsMethod = null;
private final List<Map<String, Object>> cyItems =
new ArrayList<>();
public PointsManager(Main plugin) {
this.plugin = plugin;
discoverCY();
}
private void discoverCY() {
try {
org.bukkit.plugin.Plugin p = Bukkit
.getPluginManager()
.getPlugin("CY_beibao");
if (p != null && p.isEnabled()) {
cyInstance = p;
cyGetShopItemsMethod = p.getClass()
.getMethod("onSdf1GetShopItems");
cyActivationMethod = p.getClass()
.getMethod("onSdf1Activation",
String.class,
int.class, int.class);
plugin.getLogger().info(
"[Sdf1_login] CY_beibao 联控已连接");
}
} catch (Exception ignored) {
}
}
private void refreshCYItems() {
cyItems.clear();
if (cyInstance == null
|| cyGetShopItemsMethod == null) return;
try {
Object result =
cyGetShopItemsMethod.invoke(cyInstance);
if (result == null) return;
String data = result.toString();
if (data.isEmpty()) return;
String[] items = data.split(";");
for (String itemStr : items) {
String[] p = itemStr.split("\\|");
if (p.length < 8) continue;
Map<String, Object> item =
new LinkedHashMap<>();
item.put("id", p[0]);
item.put("name", p[1]);
item.put("days",
Integer.parseInt(p[2]));
item.put("slots",
Integer.parseInt(p[3]));
item.put("price",
Integer.parseInt(p[4]));
item.put("stock",
Integer.parseInt(p[5]));
item.put("icon", p[6]);
item.put("lifetime",
"1".equals(p[7]));
cyItems.add(item);
}
} catch (Exception e) {
plugin.getLogger().warning(
"[Sdf1_login] 拉取CY商品失败: "
+ e.getMessage());
}
}
private void activateCY(String name,
int slots, int days) {
try {
if (cyInstance == null
|| cyActivationMethod == null) return;
cyActivationMethod.invoke(
cyInstance, name, slots, days);
} catch (Exception e) {
plugin.getLogger().warning(
"[Sdf1_login] CY激活失败: "
+ e.getMessage());
}
}
public int getPoints(String name) {
return ((Number) plugin.getDb()
.getField(name, "points")).intValue();
}
public Inventory createShopGUI(Player p) {
int points = getPoints(p.getName());
Inventory inv = Bukkit.createInventory(
null, 54,
"§d§l积分商城 | 当前积分: " + points);
fillBg(inv);
inv.setItem(10, mkItem(Material.EMERALD,
"§a游戏币充值",
"§7消耗积分兑换游戏币",
"§7100积分 = $1000",
"",
"§e点击兑换"));
inv.setItem(12, mkItem(Material.ENDER_CHEST,
"§b背包空间",
"§7消耗积分兑换背包格子",
"§750积分 = 9格永久空间",
"",
"§e点击兑换"));
inv.setItem(14, mkItem(Material.EXPERIENCE_BOTTLE,
"§e经验瓶",
"§7消耗积分兑换经验",
"§720积分 = 10级经验",
"",
"§e点击兑换"));
if (cyInstance != null) {
inv.setItem(31, mkItem(Material.CHEST,
"§6背包商城",
"§7来自CY_beibao的商品",
"",
"§e点击查看"));
}
inv.setItem(49, mkItem(Material.ARROW, "§7返回"));
return inv;
}
public Inventory createCYShopGUI(Player p) {
refreshCYItems();
int points = getPoints(p.getName());
int size = Math.max(54,
((cyItems.size() / 9) + 1) * 9);
if (size > 54) size = 54;
Inventory inv = Bukkit.createInventory(
null, size,
"§d§lCY背包商城 | 当前积分: " + points);
fillBg(inv);
for (int i = 0; i < cyItems.size()
&& i < 45; i++) {
Map<String, Object> item = cyItems.get(i);
String name = (String) item.get("name");
int price = ((Number) item.get("price"))
.intValue();
int stock = ((Number) item.get("stock"))
.intValue();
int days = ((Number) item.get("days"))
.intValue();
int slots = ((Number) item.get("slots"))
.intValue();
boolean lifetime = (boolean) item
.get("lifetime");
List<String> lore = new ArrayList<>();
lore.add("§7价格: §e" + price + " 积分");
if (lifetime) {
lore.add("§7格子: §e终身 +" + slots);
} else {
lore.add("§7格子: §e+" + slots);
lore.add("§7时间: §e" + days + "天");
}
if (stock == 0) {
lore.add("§7库存: §a无限");
} else if (stock == -1) {
lore.add("§7库存: §c已下架");
} else {
lore.add("§7库存: §e" + stock);
}
lore.add("");
if (points >= price && stock != -1) {
lore.add("§e点击购买");
} else if (stock == -1) {
lore.add("§c已下架");
} else {
lore.add("§c积分不足");
}
inv.setItem(i, mkItem(Material.CHEST,
"§a" + name,
lore.toArray(new String[0])));
}
inv.setItem(size - 1,
mkItem(Material.ARROW, "§7返回"));
return inv;
}
public void addItem(String name,
int price, String cmd2) {
// 追加到商品配置文件
File file = new File(
plugin.getDataFolder(),
"ShopItems.txt");
try {
java.io.FileWriter fw =
new java.io.FileWriter(file, true);
fw.write(name + ":" + price
+ ":" + cmd2 + "\n");
fw.close();
} catch (Exception e) {
e.printStackTrace();
}
}
public void removeItem(int index) {
File file = new File(
plugin.getDataFolder(),
"ShopItems.txt");
try {
java.util.List<String> lines =
new java.util.ArrayList<>();
java.io.BufferedReader br =
new java.io.BufferedReader(
new java.io.FileReader(
file));
String line;
int i = 0;
while ((line = br.readLine()) != null) {
if (line.trim().isEmpty()
|| line.startsWith("#")) {
lines.add(line);
continue;
}
if (i != index) {
lines.add(line);
}
i++;
}
br.close();
java.io.PrintWriter pw =
new java.io.PrintWriter(file);
for (String l : lines) pw.println(l);
pw.close();
} catch (Exception e) {
e.printStackTrace();
}
}
public void handleClick(Player p, int slot) {
int points = getPoints(p.getName());
switch (slot) {
case 10:
if (points >= 100) {
plugin.getDb().deductPoints(
p.getName(), 100);
if (plugin.getEconomy() != null) {
plugin.getEconomy()
.depositPlayer(p, 1000);
}
p.sendMessage(plugin.getConfig2()
.msg("points_purchase_success")
.replace("{count}", "100"));
} else {
p.sendMessage(plugin.getConfig2()
.msg("points_insufficient")
.replace("{points}",
String.valueOf(points)));
}
break;
case 12:
if (points >= 50) {
plugin.getDb().deductPoints(
p.getName(), 50);
activateCY(p.getName(), 9, 0);
p.sendMessage(plugin.getConfig2()
.msg("points_purchase_success")
.replace("{count}", "50"));
} else {
p.sendMessage(plugin.getConfig2()
.msg("points_insufficient")
.replace("{points}",
String.valueOf(points)));
}
break;
case 14:
if (points >= 20) {
plugin.getDb().deductPoints(
p.getName(), 20);
p.setLevel(p.getLevel() + 10);
p.sendMessage(plugin.getConfig2()
.msg("points_purchase_success")
.replace("{count}", "20"));
} else {
p.sendMessage(plugin.getConfig2()
.msg("points_insufficient")
.replace("{points}",
String.valueOf(points)));
}
break;
case 31:
if (cyInstance != null) {
p.openInventory(
createCYShopGUI(p));
}
break;
}
}
public void handleCYClick(Player p, int slot) {
if (slot < 0 || slot >= cyItems.size()
|| slot >= 45) return;
Map<String, Object> item = cyItems.get(slot);
int price = ((Number) item.get("price"))
.intValue();
int stock = ((Number) item.get("stock"))
.intValue();
int days = ((Number) item.get("days"))
.intValue();
int slots = ((Number) item.get("slots"))
.intValue();
boolean lifetime = (boolean) item
.get("lifetime");
if (stock == -1) {
p.sendMessage("§c该商品已下架");
return;
}
int points = getPoints(p.getName());
if (points < price) {
p.sendMessage(plugin.getConfig2()
.msg("points_insufficient")
.replace("{points}",
String.valueOf(points)));
return;
}
plugin.getDb().deductPoints(p.getName(), price);
int actDays = lifetime ? 0 : days;
activateCY(p.getName(), slots, actDays);
String tl = lifetime
? "终身" : (days + "天");
p.sendMessage("§a购买成功!获得 "
+ tl + " +" + slots + "格 背包空间");
}
private void fillBg(Inventory inv) {
ItemStack gl = mkItem(
Material.GRAY_STAINED_GLASS_PANE, " ");
for (int i = 0; i < inv.getSize(); i++)
inv.setItem(i, gl);
}
private ItemStack mkItem(Material mat, String name,
String... lore) {
ItemStack it = new ItemStack(mat);
ItemMeta im = it.getItemMeta();
if (im != null) {
im.setDisplayName(name);
if (lore.length > 0)
im.setLore(Arrays.asList(lore));
it.setItemMeta(im);
}
return it;
}
}
+138
View File
@@ -0,0 +1,138 @@
package Sdf1_login;
import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class QuestStorage {
private Connection conn;
public QuestStorage(File dataFolder) {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(
dataFolder,
"quest_data.db");
conn = DriverManager.getConnection(
"jdbc:sqlite:"
+ dbFile.getPath());
initTable();
} catch (Exception e) {
e.printStackTrace();
}
}
private void initTable()
throws SQLException {
Statement stmt =
conn.createStatement();
stmt.executeUpdate(
"CREATE TABLE IF NOT EXISTS "
+ "quest_conditions ("
+ "player TEXT NOT NULL, "
+ "condition_key TEXT NOT NULL, "
+ "value TEXT DEFAULT '1', "
+ "timestamp INTEGER, "
+ "PRIMARY KEY "
+ "(player, condition_key))");
stmt.close();
}
/**
* 标记条件已完成(value = "1")
*/
public void setCondition(
String player,
String condition) {
setConditionRaw(
player, condition, "1");
}
/**
* 写入任意值(计数器等)
*/
public void setConditionRaw(
String player,
String condition,
String value) {
try {
PreparedStatement ps =
conn.prepareStatement(
"INSERT OR REPLACE "
+ "INTO "
+ "quest_conditions "
+ "(player, "
+ "condition_key, "
+ "value, timestamp) "
+ "VALUES "
+ "(?, ?, ?, ?)");
ps.setString(1, player);
ps.setString(2, condition);
ps.setString(3, value);
ps.setLong(4,
System.currentTimeMillis());
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
/**
* 查询条件值
*/
public Object getCondition(
String player,
String condition) {
try {
PreparedStatement ps =
conn.prepareStatement(
"SELECT value FROM "
+ "quest_conditions "
+ "WHERE player = ? "
+ "AND "
+ "condition_key = ?");
ps.setString(1, player);
ps.setString(2, condition);
ResultSet rs =
ps.executeQuery();
if (rs.next()) {
String val =
rs.getString("value");
rs.close();
ps.close();
// 尝试解析为数字
try {
if (val.contains(".")) {
return Double.parseDouble(
val);
}
return Long.parseLong(val);
} catch (NumberFormatException e) {
return val;
}
}
rs.close();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
public void close() {
try {
if (conn != null
&& !conn.isClosed()) {
conn.close();
}
} catch (SQLException e) {
e.printStackTrace();
}
}
}

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