Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
165adcb532 | ||
|
|
b6914c52f9 | ||
|
|
2e04d9cf36 | ||
|
|
a33aba1b13 | ||
|
|
d18f983b83 | ||
|
|
6daedd5dbc | ||
|
|
ffeffbe7cd | ||
|
|
e440dea033 | ||
|
|
79b75a0f76 | ||
|
|
c4f6f2fa1c | ||
|
|
3c8b573d05 | ||
|
|
5175cb46fb | ||
|
|
c8a7cd73a6 | ||
|
|
6d93d69226 | ||
|
|
203545a719 | ||
|
|
716d43b8c2 | ||
|
|
80c4073445 | ||
|
|
fe882cf73a | ||
|
|
685a1dace2 | ||
|
|
f2859fe6e5 | ||
|
|
3a4252e861 | ||
|
|
4acb6712cd | ||
|
|
a0aeabf22d | ||
|
|
d0cdadd29d | ||
|
|
3e2bd64a70 | ||
|
|
9dcf5dcb93 | ||
|
|
71b644182c | ||
|
|
d4748c4e62 | ||
|
|
f62a446d03 | ||
|
|
bca9b9af47 | ||
|
|
bcc22df797 | ||
|
|
82c688c631 | ||
|
|
831b2b53d7 | ||
|
|
8337f06cda | ||
|
|
ac1b0c86fd | ||
|
|
76c08ec0f2 | ||
|
|
a2171262e6 | ||
|
|
79280c3990 | ||
|
|
af6fb3b67e | ||
|
|
b99412cc8a | ||
|
|
e12a972ad7 | ||
|
|
489b83f29d | ||
|
|
8a669b1e24 | ||
|
|
fcc8f08ef7 | ||
|
|
ed55de014b | ||
|
|
b0e572d7ac | ||
|
|
a758bde564 | ||
|
|
4bdefdab8c | ||
|
|
4be4f1f107 | ||
|
|
2cb48de453 | ||
|
|
6f13740cce | ||
|
|
1e60d31ffd | ||
|
|
0b63a99719 | ||
|
|
fc8c82b188 | ||
|
|
e1010927e5 | ||
|
|
8a6755e725 | ||
|
|
ebcdeb6f09 | ||
|
|
d76ad12119 | ||
|
|
1188da01a9 | ||
|
|
c1009088ce | ||
|
|
7dc7d01cd0 | ||
|
|
7932bd176c | ||
|
|
ba5352121e | ||
|
|
629c16ec84 | ||
|
|
aadb3373e8 | ||
|
|
eb01cb9ea8 | ||
|
|
e20d648b08 | ||
|
|
e76b38cd7d | ||
|
|
59cca9e04a | ||
|
|
dbb4843db6 | ||
|
|
6a6cb9ec3c | ||
|
|
bd47d25165 | ||
|
|
a5871f6761 | ||
|
|
6cc8dbf582 | ||
|
|
d2f42bef62 | ||
|
|
1bd51cbcdf | ||
|
|
ce489d1450 | ||
|
|
e040e8c994 | ||
|
|
99da971fbf | ||
|
|
f7aedc6fae | ||
|
|
398a9db380 | ||
|
|
c880f1eb9e | ||
|
|
386c9d861e | ||
|
|
4160c60aa6 | ||
|
|
419b677aa9 | ||
|
|
fe1ed613ba | ||
|
|
c6b05d72c8 | ||
|
|
7f513b9fa1 | ||
|
|
9ee8023d90 | ||
|
|
5912c6fd76 | ||
|
|
e18d8df22f | ||
|
|
196e20868e | ||
|
|
2f5f02b72d | ||
|
|
23c40df6b1 | ||
|
|
c0e95b6c73 | ||
|
|
293d7b283c | ||
|
|
8fa7eb5c95 | ||
|
|
1a221330dc | ||
|
|
b138171a7c | ||
|
|
ea5d476825 | ||
|
|
17d53c40a0 | ||
|
|
4f498a80c6 | ||
|
|
5400856785 | ||
|
|
2e4ddcbaea | ||
|
|
1eb898adea | ||
|
|
b86d29b34a | ||
|
|
b5b252d1b7 | ||
|
|
eb741beeb4 | ||
|
|
186e670400 | ||
|
|
43fd8e2891 | ||
|
|
b6983f99e4 | ||
|
|
4c2e075667 | ||
|
|
27da2b5c05 | ||
|
|
a97f9274cc | ||
|
|
f033e71095 | ||
|
|
51e418ac16 | ||
|
|
b7b2647ef1 | ||
|
|
a46737d64b | ||
|
|
c46c9ebdfe | ||
|
|
fd03189f31 | ||
|
|
52e07ec9de | ||
|
|
345e1e9a9b | ||
|
|
045d9f60b8 | ||
|
|
18bd1e9b19 | ||
|
|
4db131082c | ||
|
|
eaadc0bd64 | ||
|
|
f2b87325e1 | ||
|
|
6a74dfee91 | ||
|
|
d92f1f47a9 | ||
|
|
c3d9c0925d | ||
|
|
312f40c94a | ||
|
|
0098efda3f | ||
|
|
1d5f500a28 | ||
|
|
ff83406013 | ||
|
|
d539cc10bf | ||
|
|
e30c37ef64 | ||
|
|
51aaedc2b3 | ||
|
|
b7c83903ca | ||
|
|
65fc468ce1 | ||
|
|
ace705682c | ||
|
|
b90eeb2bcd | ||
|
|
7e3af6d7d7 | ||
|
|
f3919a8715 | ||
|
|
00da16abb6 | ||
|
|
5fcecdaefd | ||
|
|
e2808276c0 | ||
|
|
a8303ba772 | ||
|
|
de589d2683 | ||
|
|
433735d595 | ||
|
|
304bd8f9ef | ||
|
|
6da7f58281 | ||
|
|
95630c7609 | ||
|
|
61bc4672a9 | ||
|
|
4afc2470b0 | ||
|
|
07689f235b | ||
|
|
e0bb61f23a | ||
|
|
ea20701db5 | ||
|
|
19d0cf1618 | ||
|
|
4781996f08 | ||
|
|
d9a3a6eb33 | ||
|
|
510db12216 | ||
|
|
3893d22aef | ||
|
|
43cb042bf0 | ||
|
|
bed190ca8d | ||
|
|
9c50e58e7d | ||
|
|
767a9892f7 | ||
|
|
cb949f5b16 | ||
|
|
1cd8415516 | ||
|
|
8f54e3ffc6 | ||
|
|
449976478e | ||
|
|
3a53ec5fdc | ||
|
|
8c857b4715 | ||
|
|
ab762ad3a9 | ||
|
|
d0b9e8a92d | ||
|
|
deabd1de98 | ||
|
|
8d151d865c | ||
|
|
a4d6a5d487 | ||
|
|
efcc0be26a | ||
|
|
8ae0fe9b7d | ||
|
|
af5f31c87e | ||
|
|
7878c07a22 | ||
|
|
2a6c6edad2 | ||
|
|
7fd559531c | ||
|
|
cb2f68cfd8 | ||
|
|
57124cab5c | ||
|
|
1c9e5808a1 | ||
|
|
8ec89ef841 | ||
|
|
9a78c693bf | ||
|
|
5515d22d4c | ||
|
|
afdae1b63b | ||
|
|
6984bc79fd | ||
|
|
8261b4dbfd | ||
|
|
984c629cc0 | ||
|
|
6ad469452f | ||
|
|
8f0bf8cd52 | ||
|
|
ffa8b4cce0 | ||
|
|
ba484e957d | ||
|
|
dff381adeb | ||
|
|
19472baab0 | ||
|
|
5e581adef3 | ||
|
|
4e11845636 | ||
|
|
b34753d1fd | ||
|
|
498245d967 | ||
|
|
be04c8b451 | ||
|
|
f5879f4087 | ||
|
|
0760cc1d2e | ||
|
|
8645c57839 | ||
|
|
0b283483a7 | ||
|
|
9b39c9e56f | ||
|
|
c266cf8555 | ||
|
|
a4972dde7e | ||
|
|
d7da6ec1a6 | ||
|
|
0115cbda41 | ||
|
|
ed57c8f82c | ||
|
|
4998d7bdfa | ||
|
|
646ecfa162 | ||
|
|
eaeab955e3 | ||
|
|
a28083f4ca | ||
|
|
b67480d073 | ||
|
|
555b9382b5 | ||
|
|
b63ecc02b8 | ||
|
|
1238c6a20c | ||
|
|
e49fcaccd8 | ||
|
|
eed83a552f | ||
|
|
c960c3ac0c | ||
|
|
ae1ff5ffd8 | ||
|
|
1e97285459 | ||
|
|
c1234c6700 | ||
|
|
e076596b4d | ||
|
|
e9ecb0ae53 | ||
|
|
ab7443b5b5 | ||
|
|
77ee56cca4 | ||
|
|
3f3fbd68bb | ||
|
|
d9cc806177 | ||
|
|
c3ee6a5c5a | ||
|
|
cb65e34f2d | ||
|
|
1ea6f4963f | ||
|
|
28c7aa119e | ||
|
|
ac79628076 | ||
|
|
02dc706635 | ||
|
|
d0be292a78 | ||
|
|
822f47dd05 | ||
|
|
78e3b2ff1e | ||
|
|
8286a4c09c | ||
|
|
6775465c21 | ||
|
|
9c269cc184 | ||
|
|
62f53234e7 | ||
|
|
a72bbfd970 | ||
|
|
6359830105 | ||
|
|
7a4aad46c6 | ||
|
|
fdd8ccb340 | ||
|
|
9456f787f8 | ||
|
|
6634fdefa0 | ||
|
|
f32c7cfe86 | ||
|
|
86b2d6ebdf | ||
|
|
c22386b8fc | ||
|
|
59701d1627 | ||
|
|
0e1f26508b | ||
|
|
8de2cffe82 | ||
|
|
dd425f4946 | ||
|
|
7a283ae4e0 | ||
|
|
e46ee109d3 | ||
|
|
ffb6e24d5f | ||
|
|
31493de10d | ||
|
|
1277c1fdf3 | ||
|
|
5fa59b71b2 | ||
|
|
591d71ede0 | ||
|
|
4927aaed01 | ||
|
|
6337bc3e75 | ||
|
|
d394bd0ec8 | ||
|
|
60151dc1d0 | ||
|
|
35eb054e4f | ||
|
|
bcc68aa4e5 | ||
|
|
b9ca56ff7f | ||
|
|
fc6a46597d | ||
|
|
1bc97f6980 | ||
|
|
6d68c4a05b | ||
|
|
3dacc80fe1 | ||
|
|
62798f2a90 | ||
|
|
64ed1e6ada | ||
|
|
5403479a2c | ||
|
|
e9bd63a1e4 | ||
|
|
866b5abe15 | ||
|
|
2237f6c855 | ||
|
|
3925dea586 | ||
|
|
02c2e91531 | ||
|
|
c0afb47535 | ||
|
|
f6265dd994 | ||
|
|
2342cc2cab | ||
|
|
35f15db034 | ||
|
|
1f97e3b969 | ||
|
|
72d2032f95 | ||
|
|
d568a2ce5d | ||
|
|
5f3bd70987 | ||
|
|
ca212233a3 | ||
|
|
a5a2ef733c | ||
|
|
5f43fb1407 | ||
|
|
c0672ad47a | ||
|
|
63251c6033 | ||
|
|
742578a107 | ||
|
|
c8c8db6220 | ||
|
|
fc42b777ca | ||
|
|
48ad7035ea | ||
|
|
b6db8e56d5 | ||
|
|
bbd0049602 | ||
|
|
b8dd9b7cd0 | ||
|
|
5c47dd44fd | ||
|
|
967feb9b94 | ||
|
|
9905f28886 | ||
|
|
3dfa7df18c | ||
|
|
8de90708dc | ||
|
|
aa45bd5ca2 | ||
|
|
57fbc1ffc1 | ||
|
|
3ad96b1080 | ||
|
|
9eb0707729 | ||
|
|
cd86441a7a | ||
|
|
cd6010bfbb | ||
|
|
8edc94b848 | ||
|
|
71777e3013 | ||
|
|
06854054ca | ||
|
|
02d2d95c9a | ||
|
|
d12ceaa44a | ||
|
|
98f9cb6410 | ||
|
|
b50bfd4379 | ||
|
|
15133de0f9 | ||
|
|
ed74d09b6a | ||
|
|
66cc7b8925 | ||
|
|
4c7ec7490f | ||
|
|
d7a986894c | ||
|
|
885e70f0f5 | ||
|
|
9bae160ce0 | ||
|
|
0c9a5b0f53 | ||
|
|
54cd3c4f59 |
@@ -1,5 +0,0 @@
|
||||
*.md text=auto encoding=UTF-8
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
@@ -1,21 +0,0 @@
|
||||
name: Check Source
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check workspace
|
||||
run: |
|
||||
echo "=== PWD ==="
|
||||
pwd
|
||||
echo "=== LS ==="
|
||||
ls -la
|
||||
echo "=== FIND POM ==="
|
||||
find / -name "pom.xml" 2>/dev/null
|
||||
echo "=== ENV ==="
|
||||
env | grep -iE "workspace|repo|git" | sort
|
||||
echo "=== DONE ==="
|
||||
@@ -1,123 +0,0 @@
|
||||
name: Auto Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Setup JDK 25
|
||||
run: |
|
||||
echo "Setting up JDK 25..."
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq wget unzip
|
||||
wget -q https://api.adoptium.net/v3/binary/latest/25/ga/linux/x64/jdk/hotspot/normal/eclipse?project=jdk -O /tmp/jdk25.tar.gz
|
||||
tar -xzf /tmp/jdk25.tar.gz -C /opt
|
||||
export JAVA_HOME=$(ls -d /opt/jdk-25* | head -1)
|
||||
echo "JAVA_HOME=$JAVA_HOME" >> $GITHUB_ENV
|
||||
echo "$JAVA_HOME/bin" >> $GITHUB_PATH
|
||||
echo "JDK 25 installed: $($JAVA_HOME/bin/java -version 2>&1 | head -1)"
|
||||
echo "Setup completed"
|
||||
|
||||
- name: Setup Maven
|
||||
run: |
|
||||
echo "Setting up Maven..."
|
||||
apt-get install -y -qq maven
|
||||
echo "Maven version: $(mvn -version 2>&1 | head -1)"
|
||||
echo "Setup completed"
|
||||
|
||||
- name: Checkout code
|
||||
run: |
|
||||
echo "Checking out code..."
|
||||
GIT_HOST=${GITHUB_SERVER_URL#http://}
|
||||
GIT_HOST=${GIT_HOST#https://}
|
||||
rm -rf /workspace/youpaishidifu/Sdf1_login/*
|
||||
rm -rf /workspace/youpaishidifu/Sdf1_login/.[!.]*
|
||||
git clone "http://${GIT_HOST}/$(echo ${GITHUB_REPOSITORY} | tr '[:upper:]' '[:lower:]').git" .
|
||||
echo "Checkout completed"
|
||||
|
||||
- name: Build with Maven
|
||||
run: |
|
||||
echo "Building with Maven..."
|
||||
mvn -B clean package -DskipTests
|
||||
echo "Build completed"
|
||||
|
||||
- name: Verify JAR
|
||||
run: |
|
||||
echo "Verifying JAR..."
|
||||
ls -la target/*.jar
|
||||
echo "Verification completed"
|
||||
|
||||
- name: Generate Release Notes
|
||||
run: |
|
||||
echo "Generating release notes..."
|
||||
VERSION=$(grep "^version:" src/main/resources/plugin.yml | awk '{print $2}' | tr -d '\r\n ')
|
||||
TAG="v$VERSION"
|
||||
|
||||
# Generate release notes from last 10 commits
|
||||
echo "## Changes in $TAG" > /tmp/release_body.md
|
||||
echo "" >> /tmp/release_body.md
|
||||
git log --format="- **%h** %s (%an, %ad)" --date=short -10 >> /tmp/release_body.md
|
||||
echo "" >> /tmp/release_body.md
|
||||
echo "### Full changelog" >> /tmp/release_body.md
|
||||
echo "" >> /tmp/release_body.md
|
||||
echo "[View all commits](/youpaishidifu/Sdf1_login/commits/main)" >> /tmp/release_body.md
|
||||
|
||||
echo "Release notes generated for $TAG"
|
||||
cat /tmp/release_body.md
|
||||
|
||||
- name: Create Release
|
||||
env:
|
||||
GITEA_TOKEN: 194187cbdc8e1f1d3bc810b1b9e07ddc904a1f73
|
||||
run: |
|
||||
set -e
|
||||
VERSION=$(grep "^version:" src/main/resources/plugin.yml | awk '{print $2}' | tr -d '\r\n ')
|
||||
TAG="v$VERSION"
|
||||
BASE="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY"
|
||||
echo "Version: $VERSION, Tag: $TAG"
|
||||
|
||||
echo "=== Checking existing tag $TAG ==="
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $GITEA_TOKEN" "$BASE/releases/tags/$TAG")
|
||||
echo "Check tag HTTP: $HTTP_CODE"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
OLD_ID=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$BASE/releases/tags/$TAG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
|
||||
echo "Tag $TAG exists (release #$OLD_ID), deleting..."
|
||||
curl -s -X DELETE -H "Authorization: token $GITEA_TOKEN" "$BASE/releases/$OLD_ID"
|
||||
curl -s -X DELETE -H "Authorization: token $GITEA_TOKEN" "$BASE/tags/$TAG"
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
echo "=== Creating release ==="
|
||||
# Use python3 to construct proper JSON
|
||||
python3 -c "
|
||||
import json, sys
|
||||
body = open('/tmp/release_body.md').read()
|
||||
data = {'tag_name': '$TAG', 'name': '$TAG', 'body': body, 'draft': False, 'prerelease': False}
|
||||
print(json.dumps(data))
|
||||
" > /tmp/release_payload.json
|
||||
|
||||
RESP=$(curl -s -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$BASE/releases" \
|
||||
-d @/tmp/release_payload.json)
|
||||
|
||||
RELEASE_ID=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
|
||||
echo "Created release #$RELEASE_ID"
|
||||
|
||||
echo "=== Uploading artifact ==="
|
||||
JAR=$(ls target/*.jar | head -1)
|
||||
UPLOAD_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/jar" \
|
||||
"$BASE/releases/$RELEASE_ID/assets?name=$(basename $JAR)" \
|
||||
--data-binary "@$JAR")
|
||||
echo "Upload HTTP: $UPLOAD_CODE"
|
||||
|
||||
echo "=== Release $TAG created successfully ==="
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
name: Simple Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Hello
|
||||
run: echo "Hello from Gitea Actions!"
|
||||
@@ -1,15 +0,0 @@
|
||||
name: Test Env
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Show env
|
||||
run: |
|
||||
echo "=== ENV ==="
|
||||
env | sort
|
||||
echo "=== DONE ==="
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
name: Bug 反馈
|
||||
about: 报告一个 Bug 以帮助改进插件
|
||||
title: "[Bug] "
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Bug 描述
|
||||
请简明扼要地描述 Bug 是什么。
|
||||
|
||||
## 复现步骤
|
||||
1. 进入 ...
|
||||
2. 点击 ...
|
||||
3. 出现错误 ...
|
||||
|
||||
## 期望行为
|
||||
描述你期望发生的事情。
|
||||
|
||||
## 实际行为
|
||||
描述实际发生了什么。
|
||||
|
||||
## 服务端信息
|
||||
- 服务器类型和版本(如 Paper 1.21.4):
|
||||
- 插件版本:
|
||||
|
||||
## 截图 / 日志
|
||||
如有相关截图或报错日志,请粘贴在此处。
|
||||
|
||||
## 其他信息
|
||||
补充任何你认为有助于解决问题的信息。
|
||||
@@ -1,5 +0,0 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 问题排查指南
|
||||
url: https://github.com/ypsdf1/sdf1_login
|
||||
about: 在提交 Bug 前,请先查阅文档确认是否已有相关说明。
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: 功能新增
|
||||
about: 提出一个新功能的需求建议
|
||||
title: "[Feature] "
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## 功能描述
|
||||
请描述你希望添加的新功能。
|
||||
|
||||
## 动机
|
||||
为什么需要这个功能?它能解决什么问题?
|
||||
|
||||
## 建议的实现方式
|
||||
如果对实现方式有想法,请在此描述。
|
||||
|
||||
## 额外信息
|
||||
补充任何你认为有助于理解需求的上下文。
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
name: 建议提交
|
||||
about: 对插件提出改进建议或反馈
|
||||
title: "[Suggestion] "
|
||||
labels: suggestion
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## 建议内容
|
||||
请描述你的建议或想法。
|
||||
|
||||
## 背景
|
||||
什么场景下产生的这个建议?
|
||||
|
||||
## 预期效果
|
||||
你认为采用这个建议后能达到什么效果?
|
||||
@@ -1,39 +0,0 @@
|
||||
name: Auto Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from plugin.yml
|
||||
id: version
|
||||
run: |
|
||||
echo "version=$(grep '^version: ' src/main/resources/plugin.yml | sed 's/version: //')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up JDK 26
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '26'
|
||||
distribution: 'zulu'
|
||||
|
||||
- name: Build with Maven
|
||||
run: mvn -B clean package -DskipTests
|
||||
|
||||
- name: Verify JAR exists
|
||||
run: ls -la target/Sdf1_login.jar
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ steps.version.outputs.version }}
|
||||
name: v${{ steps.version.outputs.version }}
|
||||
files: target/Sdf1_login.jar
|
||||
generate_release_notes: true
|
||||
@@ -1,125 +1,63 @@
|
||||
target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
.kotlin
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea/modules.xml
|
||||
.idea/jarRepositories.xml
|
||||
.idea/compiler.xml
|
||||
.idea/libraries/
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### Eclipse ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
|
||||
# 开发辅助文件(本地保留,不推送到仓库)
|
||||
/代码关联关系.md
|
||||
txt/
|
||||
*.xlsx
|
||||
*.patch
|
||||
backup/
|
||||
新建*.txt
|
||||
群戏归类.txt
|
||||
upload_sftp.py
|
||||
*.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 永不上传它,避免覆盖线上真值。
|
||||
|
||||
# PHP Web 后端
|
||||
web/Web.db
|
||||
web/debug_log_tail.txt
|
||||
web/upload_debug.ps1
|
||||
web/api/*.bak*
|
||||
# 敏感文件 - 禁止提交上传/调试脚本
|
||||
web/upload_*.php
|
||||
web/self_update.php
|
||||
web/version_check.php
|
||||
web/debug_*.php
|
||||
web/db/debug_*.php
|
||||
# 支付密钥 - 严禁提交 (double insurance,配合 Web/.gitignore)
|
||||
Web/密钥文件_git拉黑.txt
|
||||
web/密钥文件_git拉黑.txt
|
||||
Web/pay_secrets.php
|
||||
web/pay_secrets.php
|
||||
# 开发辅助文档(本地保留,不推送到仓库)
|
||||
/代码关联关系.md
|
||||
|
||||
# Windows 保留设备名(无法用普通 rm 删除)
|
||||
nul
|
||||
con
|
||||
prn
|
||||
aux
|
||||
com1
|
||||
com2
|
||||
com3
|
||||
com4
|
||||
com5
|
||||
com6
|
||||
com7
|
||||
com8
|
||||
com9
|
||||
lpt1
|
||||
lpt2
|
||||
lpt3
|
||||
lpt4
|
||||
lpt5
|
||||
lpt6
|
||||
lpt7
|
||||
lpt8
|
||||
lpt9hs_err_pid*.log
|
||||
hs_err_pid*.log
|
||||
replay_pid*.log
|
||||
# ===== 支付密钥 - 严禁提交 (double insurance) =====
|
||||
# 彩虹易支付 RSA 私钥/公钥/商户ID 全部在此文件,一旦进 git 或进公网 web 根目录即泄露
|
||||
密钥文件_git拉黑.txt
|
||||
pay_secrets.php
|
||||
*.paykey
|
||||
# 屏蔽无用的db文件夹(仓库不需要db文件)
|
||||
db/
|
||||
/*.rar
|
||||
|
||||
# 屏蔽patch文件
|
||||
*.patch
|
||||
# 屏蔽wb产生的工作文件
|
||||
.workbuddy/
|
||||
# 屏蔽备份文件
|
||||
backup/
|
||||
# 屏蔽根目录下的所有json
|
||||
/*.json
|
||||
# 屏蔽垃圾文件
|
||||
*.idx
|
||||
*.pack
|
||||
*.rar
|
||||
test.*
|
||||
/*.rev
|
||||
# 屏蔽全部日志文件
|
||||
*.log
|
||||
# 屏蔽开发计划相关文件
|
||||
*.xlsx
|
||||
*.docs
|
||||
*.doc
|
||||
*.txt
|
||||
# 屏蔽全部部署脚本(wb用)
|
||||
*.py
|
||||
# 屏蔽其它文件
|
||||
# 屏蔽所有md文件、python部署脚本
|
||||
*.md
|
||||
# 过滤readme和配置文件
|
||||
*.py
|
||||
# 例外:readme、pay.md和sql.md
|
||||
!readme.md
|
||||
!scr/main/resources
|
||||
!src/main/resources/**/*.*
|
||||
!pay.md
|
||||
!sql.md
|
||||
# 屏蔽所有的python脚本
|
||||
/*.py
|
||||
# 屏蔽备份文件
|
||||
/*.*.back.*
|
||||
# 屏蔽所有的测试文件
|
||||
/*_*.*
|
||||
/_test*/*.*
|
||||
# ===== .workbuddy/ 工作目录(用户保留区)=====
|
||||
# 存放测试脚本 / 探针 / 测试数据 / 备份文件,已被 git拉黑(2026-10-09)
|
||||
.workbuddy/
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# 已忽略包含查询文件的默认文件夹
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding">
|
||||
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="MavenProjectsManager">
|
||||
<option name="originalFiles">
|
||||
<list>
|
||||
<option value="$PROJECT_DIR$/pom.xml" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="26" project-jdk-type="JavaSDK" />
|
||||
</project>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/core.php';
|
||||
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
|
||||
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||
|
||||
$isApi = (getParam('action') === 'data');
|
||||
|
||||
function getActivePlayers() {
|
||||
$online = []; $count = 0;
|
||||
try {
|
||||
$db = getDB();
|
||||
// 确保表存在
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS hourly_active_players (player_name TEXT PRIMARY KEY, first_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL, hour_key TEXT NOT NULL)");
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS online_players (player_name TEXT PRIMARY KEY, login_time INTEGER DEFAULT 0)");
|
||||
$currentHourKey = date('Y-m-d H');
|
||||
// ★ 查询本小时活跃过的所有玩家(包括已离线但本小时曾上线的)
|
||||
$res = $db->query("SELECT player_name, first_seen, last_seen FROM hourly_active_players WHERE hour_key = '" . SQLite3::escapeString($currentHourKey) . "' ORDER BY first_seen ASC");
|
||||
$now = time();
|
||||
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
|
||||
// 检查玩家当前是否在线(从online_players表判断)
|
||||
$checkStmt = $db->prepare("SELECT 1 FROM online_players WHERE player_name = :name");
|
||||
$checkStmt->bindValue(':name', $row['player_name'], SQLITE3_TEXT);
|
||||
$checkRes = $checkStmt->execute();
|
||||
$isOnline = $checkRes->fetchArray() !== false;
|
||||
|
||||
$online[] = [
|
||||
'name' => $row['player_name'],
|
||||
'login_time' => (int)$row['first_seen'],
|
||||
'login_time_fmt' => $row['first_seen'] > 0 ? date('H:i:s', (int)$row['first_seen']) : '-',
|
||||
'duration' => $row['first_seen'] > 0 ? $now - (int)$row['first_seen'] : 0,
|
||||
'is_online' => $isOnline,
|
||||
'last_seen' => (int)$row['last_seen'],
|
||||
];
|
||||
}
|
||||
$count = count($online);
|
||||
} catch (\Throwable $e) {
|
||||
debugLog('[active_players] 查询异常: ' . $e->getMessage());
|
||||
}
|
||||
return ['online_list' => $online, 'online_count' => $count];
|
||||
}
|
||||
|
||||
if ($isApi) {
|
||||
success(getActivePlayers());
|
||||
exit;
|
||||
}
|
||||
|
||||
$initialState = json_encode(getActivePlayers(), JSON_UNESCAPED_UNICODE);
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate">
|
||||
<title>SDF1 - 在线活跃</title>
|
||||
<style>
|
||||
:root { --bg:#0d1117; --card:#161b22; --border:#30363d; --text:#e6edf3; --dim:#8b949e; --accent:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d29922; --purple:#bc8cff; }
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { background:var(--bg); color:var(--text); font-family:'Segoe UI',system-ui,sans-serif; min-height:100vh; }
|
||||
.header { background:linear-gradient(135deg,#1a1e2e,#0d1117); border-bottom:1px solid var(--border); padding:14px 24px; display:flex; justify-content:space-between; align-items:center; position:sticky; top:0; z-index:50; }
|
||||
.header h1 { font-size:18px; color:var(--accent); }
|
||||
.header .info { font-size:13px; color:var(--dim); }
|
||||
.container { max-width:900px; margin:20px auto; padding:0 16px; }
|
||||
|
||||
/* 在线玩家卡片网格 */
|
||||
.grid-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; flex-wrap:wrap; gap:8px; }
|
||||
.grid-header h2 { font-size:15px; color:var(--accent); }
|
||||
.grid-header .badge { background:var(--green); color:#000; padding:4px 12px; border-radius:20px; font-size:13px; font-weight:700; }
|
||||
|
||||
.player-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:12px; }
|
||||
.player-card { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:14px 16px; transition:border-color .2s; }
|
||||
.player-card:hover { border-color:var(--accent); }
|
||||
.player-card.offline { opacity:0.55; border-style:dashed; }
|
||||
.player-card .name { font-size:15px; font-weight:700; color:var(--text); margin-bottom:4px; display:flex; align-items:center; gap:6px; }
|
||||
.player-card .name .status-dot { width:8px; height:8px; border-radius:50%; display:inline-block; flex-shrink:0; }
|
||||
.player-card .name .status-dot.on { background:var(--green); box-shadow:0 0 6px var(--green); }
|
||||
.player-card .name .status-dot.off { background:var(--dim); }
|
||||
.player-card .meta { font-size:12px; color:var(--dim); display:flex; justify-content:space-between; }
|
||||
.player-card .meta .online-tag { color:var(--green); }
|
||||
.player-card .meta .offline-tag { color:var(--yellow); }
|
||||
.player-card .time-bar { margin-top:8px; height:4px; background:var(--bg); border-radius:4px; overflow:hidden; }
|
||||
.player-card .time-bar .fill { height:100%; background:var(--green); border-radius:4px; transition:width 1s; }
|
||||
.empty { grid-column:1/-1; text-align:center; padding:40px; color:var(--dim); }
|
||||
.refresh-hint { text-align:center; font-size:12px; color:var(--dim); margin-top:16px; }
|
||||
#loading { text-align:center; padding:40px; color:var(--dim); }
|
||||
@media(max-width:500px) {
|
||||
.player-grid { grid-template-columns:1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🟢 本小时在线活跃</h1>
|
||||
<div class="info">每10秒刷新</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div id="loading">加载中...</div>
|
||||
<div id="content" style="display:none">
|
||||
<div class="grid-header">
|
||||
<h2>👤 当前在线玩家</h2>
|
||||
<span class="badge" id="countBadge">0 人</span>
|
||||
</div>
|
||||
<div class="player-grid" id="playerGrid"></div>
|
||||
<div class="refresh-hint" id="refreshHint"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const state = <?= $initialState ?>;
|
||||
function fmtDuration(sec) {
|
||||
if (sec < 60) return sec + '秒';
|
||||
if (sec < 3600) return Math.floor(sec/60) + '分' + (sec%60) + '秒';
|
||||
return Math.floor(sec/3600) + '时' + Math.floor((sec%3600)/60) + '分';
|
||||
}
|
||||
function render(data) {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('content').style.display = 'block';
|
||||
document.getElementById('countBadge').textContent = data.online_count + ' 人';
|
||||
const grid = document.getElementById('playerGrid');
|
||||
if (data.online_list.length === 0) {
|
||||
grid.innerHTML = '<div class="empty">本小时暂无玩家活跃</div>';
|
||||
} else {
|
||||
// 在线玩家排前面,离线玩家排后面
|
||||
const sorted = [...data.online_list].sort((a, b) => (b.is_online ? 1 : 0) - (a.is_online ? 1 : 0));
|
||||
grid.innerHTML = sorted.map(p => {
|
||||
const mx = 6 * 3600;
|
||||
const pct = Math.min(100, Math.round(p.duration / mx * 100));
|
||||
const cls = p.is_online ? 'player-card' : 'player-card offline';
|
||||
const dot = p.is_online ? '<span class="status-dot on"></span>' : '<span class="status-dot off"></span>';
|
||||
const tag = p.is_online
|
||||
? `<span class="online-tag">在线 ${fmtDuration(p.duration)}</span>`
|
||||
: `<span class="offline-tag">已离线</span>`;
|
||||
return `<div class="${cls}"><div class="name">${dot}${esc(p.name)}</div><div class="meta"><span>首次:${p.login_time_fmt}</span>${tag}</div><div class="time-bar"><div class="fill" style="width:${pct}%;background:${p.is_online ? 'var(--green)' : 'var(--dim)'}"></div></div></div>`;
|
||||
}).join('');
|
||||
}
|
||||
document.getElementById('refreshHint').textContent = '更新:' + new Date().toLocaleTimeString('zh-CN', {hour12:false});
|
||||
}
|
||||
function esc(s) { const d=document.createElement('div'); d.textContent=s||''; return d.innerHTML; }
|
||||
render(state);
|
||||
setInterval(() => {
|
||||
fetch('active_players.php?action=data&_t=' + Date.now()).then(r=>r.json()).then(d => {
|
||||
if (d.success) render(d.data);
|
||||
}).catch(()=>{});
|
||||
}, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
/**
|
||||
* 第一层二次验证 —— 6 位动态码验证页
|
||||
* 兼容 1Password / Microsoft Authenticator / Google Authenticator 等标准 RFC6238 认证器。
|
||||
* 登录成功且 SEC_2FA_ENABLED=true 时,前端跳转到本页;验证通过后进入 admin.php。
|
||||
*/
|
||||
require_once __DIR__ . '/security.php';
|
||||
secEnsureSession();
|
||||
// IP 白名单闸门;boot 引导会话(配置流程中转)也放行
|
||||
secGatePage(true);
|
||||
|
||||
// ★ boot 恢复模式(2026-10-05):持有 boot 凭据但 IP 不在白名单 → 直接送回引导配置页。
|
||||
// 不加这一跳的话,下面的链路对非白名单 IP 全是 404 死胡同:
|
||||
// 未登录 → 跳 admin_login.php → 404;已登录已过2FA → 跳 admin.php?token= → 404
|
||||
// (线上 09:35:58 boot_unlock 后 1 秒就吃了 admin.php 的 ip_block,实录见 db/security.log)。
|
||||
// 白名单校验本身没有被绕过 —— 本页对非白名单 IP 依旧不渲染,只是不再把人丢去 404。
|
||||
if (!secIpAllowed() && secBootAuthenticate()) {
|
||||
secBootRecoveryRedirect();
|
||||
}
|
||||
|
||||
$enabled = defined('SEC_2FA_ENABLED') && SEC_2FA_ENABLED;
|
||||
$err = '';
|
||||
|
||||
// 未启用 → 引导去配置页
|
||||
if (!$enabled) {
|
||||
header('Location: admin_2fa_setup.php');
|
||||
exit;
|
||||
}
|
||||
// 未登录 / 旧纪元会话 → 回登录页
|
||||
// (本页只依赖 security.php,不加载 core.php,故用等价内联判定)
|
||||
if (empty($_SESSION['admin_auth']) || !secSessionEpochOk()) {
|
||||
header('Location: admin_login.php');
|
||||
exit;
|
||||
}
|
||||
// 已通过 → 直接进后台(★ 第三层:必须带上入口令牌,否则 admin.php 会返回 404)
|
||||
if (!empty($_SESSION['admin_2fa_ok'])) {
|
||||
header('Location: ' . secTokenUrl('admin.php'));
|
||||
exit;
|
||||
}
|
||||
|
||||
// ===== POST:校验动态码 =====
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$sid = session_id();
|
||||
$lock = secThrottleLocked('twofa_fail', $sid, 5, 900);
|
||||
if ($lock > 0) {
|
||||
$err = '验证失败次数过多,已锁定,请 ' . $lock . ' 秒后再试';
|
||||
secLog('2fa_throttled', 'lock=' . $lock . 's');
|
||||
} else {
|
||||
$code = isset($_POST['code']) ? (string)$_POST['code'] : '';
|
||||
if (secTotpVerify(SEC_2FA_SECRET, $code)) {
|
||||
$_SESSION['admin_2fa_ok'] = time();
|
||||
$_SESSION['admin_2fa_ip'] = secClientIp();
|
||||
secThrottleReset('twofa_fail', $sid);
|
||||
secLog('2fa_pass', 'session granted');
|
||||
header('Location: ' . secTokenUrl('admin.php'));
|
||||
exit;
|
||||
}
|
||||
$left = secThrottleFail('twofa_fail', $sid, 5, 900);
|
||||
// ★ 诊断:把"密钥录错/绑了旧密钥"和"设备时钟漂移"两种失败原因区分开,
|
||||
// 否则用户只能看到一句"无效",无从下手。
|
||||
$off = secTotpDiagnose(SEC_2FA_SECRET, $code);
|
||||
secLog('2fa_fail', $left > 0 ? 'LOCKED_' . $left . 's'
|
||||
: ($off === null ? 'wrong_secret' : 'clock_skew ' . $off . 's'));
|
||||
$err = $left > 0
|
||||
? '验证失败次数过多,已锁定,请 ' . $left . ' 秒后再试'
|
||||
: secTotpDiagnoseMsg($off);
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SDF1 - 二次验证</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #0d1117; color: #e6edf3;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
display: flex; justify-content: center; align-items: center; min-height: 100vh;
|
||||
}
|
||||
.box {
|
||||
background: #161b22; border: 1px solid #30363d; border-radius: 12px;
|
||||
padding: 40px; width: 400px; max-width: 92%;
|
||||
}
|
||||
.box h1 { text-align: center; color: #58a6ff; margin-bottom: 8px; font-size: 22px; }
|
||||
.box .sub { text-align: center; color: #8b949e; font-size: 13px; margin-bottom: 24px; line-height: 1.7; }
|
||||
.code-input {
|
||||
width: 100%; padding: 14px; background: #0d1117; border: 1px solid #30363d;
|
||||
border-radius: 8px; color: #e6edf3; font-size: 30px; letter-spacing: 10px;
|
||||
text-align: center; outline: none; font-family: monospace;
|
||||
}
|
||||
.code-input:focus { border-color: #58a6ff; }
|
||||
.btn {
|
||||
width: 100%; padding: 12px; background: #58a6ff; color: #fff; border: none;
|
||||
border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; margin-top: 16px;
|
||||
}
|
||||
.btn:hover { background: #79c0ff; }
|
||||
.timer-bar { height: 4px; background: #30363d; border-radius: 2px; margin-top: 14px; overflow: hidden; }
|
||||
.timer-bar .fill { height: 100%; background: #3fb950; width: 100%; transition: width .3s linear; }
|
||||
.timer-text { font-size: 11px; color: #8b949e; text-align: center; margin-top: 6px; }
|
||||
.error {
|
||||
color: #f85149; font-size: 13px; text-align: center; margin-top: 14px;
|
||||
background: rgba(248,81,73,0.1); border: 1px solid rgba(248,81,73,0.3);
|
||||
border-radius: 6px; padding: 8px; display: block;
|
||||
}
|
||||
.links { text-align: center; margin-top: 18px; }
|
||||
.links a { color: #58a6ff; text-decoration: none; font-size: 13px; }
|
||||
.ip { text-align: center; font-size: 11px; color: #8b949e; margin-top: 16px; }
|
||||
.ip b { color: #d29922; font-family: monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h1>🛡️ 二次验证</h1>
|
||||
<div class="sub">请输入认证器 App 中的 <b style="color:#e6edf3">6 位动态码</b><br>
|
||||
(1Password / Microsoft Authenticator / Google Authenticator 均可)</div>
|
||||
<form method="post" autocomplete="off">
|
||||
<input class="code-input" id="code" name="code" inputmode="numeric" pattern="[0-9]*"
|
||||
maxlength="6" placeholder="000000" autofocus>
|
||||
<button class="btn" type="submit" id="submitBtn">验 证</button>
|
||||
<div class="timer-bar"><div class="fill" id="tfill"></div></div>
|
||||
<div class="timer-text" id="ttext">动态码每 30 秒刷新</div>
|
||||
<div class="timer-text" id="srvtime" style="color:#58a6ff"></div>
|
||||
<?php if ($err !== ''): ?>
|
||||
<div class="error" style="white-space:pre-wrap;text-align:left"><?php echo htmlspecialchars($err, ENT_QUOTES, 'UTF-8'); ?></div>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
<div class="links">
|
||||
<a href="admin_login.php">← 返回登录</a> |
|
||||
<a href="admin_2fa_setup.php">安全配置</a>
|
||||
</div>
|
||||
<div class="ip">当前访问 IP:<b><?php echo htmlspecialchars(secClientIp(), ENT_QUOTES, 'UTF-8'); ?></b></div>
|
||||
<div class="ip" style="margin-top:4px">提示:认证器与上方<b>服务器时间</b>偏差超过 90 秒就会被拒</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var input = document.getElementById('code');
|
||||
input.addEventListener('input', function() {
|
||||
this.value = this.value.replace(/\D/g, '').slice(0, 6);
|
||||
});
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && this.value.length === 6) {
|
||||
document.querySelector('form').submit();
|
||||
}
|
||||
});
|
||||
// ★ 进度条/倒计时改用【服务器时钟】:
|
||||
// 手机时间不准时,用浏览器自身时钟画进度条会误导用户(看着还剩 20 秒,
|
||||
// 服务器那边其实已经翻到下一窗格了),所以以 PHP 渲染时刻为基准推进。
|
||||
var srvBase = <?php echo time(); ?>; // 页面渲染时的服务器 Unix 时间
|
||||
var srvOff = <?php echo (new DateTime('now'))->getOffset(); ?>; // 服务器时区偏移(秒)
|
||||
var t0 = Date.now();
|
||||
function srvNow() { return srvBase + Math.floor((Date.now() - t0) / 1000); }
|
||||
function pad(n) { return (n < 10 ? '0' : '') + n; }
|
||||
var fill = document.getElementById('tfill');
|
||||
var text = document.getElementById('ttext');
|
||||
var srvBox = document.getElementById('srvtime');
|
||||
function tick() {
|
||||
var s = srvNow();
|
||||
var left = 30 - (s % 30);
|
||||
fill.style.width = (left / 30 * 100) + '%';
|
||||
var d = new Date((s + srvOff) * 1000);
|
||||
text.textContent = left + ' 秒后动态码刷新';
|
||||
srvBox.textContent = '服务器时间 ' + pad(d.getUTCHours()) + ':' + pad(d.getUTCMinutes()) + ':' + pad(d.getUTCSeconds())
|
||||
+ '(与你手机差多少秒请自行比对)';
|
||||
}
|
||||
tick();
|
||||
setInterval(tick, 300);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
// ★ 紧急加固(2026-09-29):第二层 IP 白名单 —— 登录页本身也只对白名单 IP 开放
|
||||
require_once __DIR__ . '/security.php';
|
||||
secGatePage();
|
||||
// ★ 首跑顺序(2026-10-09):装库先于安全配置 —— install.php 还没跑完就先去装库。
|
||||
// 引导模式下 secGatePage 已先行 302;这里兜住的是「本机 127.0.0.1 直连」与
|
||||
// 「已引导但装库没做过」两种入口。secTokenUrl 在令牌未配置时原样返回
|
||||
// install.php,配了令牌则带上 ?token= 过第三层门禁(与 admin.php 同款拼法)。
|
||||
if (secInstallPending()) {
|
||||
header('Location: ' . secTokenUrl('install.php'));
|
||||
exit;
|
||||
}
|
||||
// ★ 首次部署:管理密码还是分发占位符 → 登录页是个死胡同(要密码,可密码还不存在)。
|
||||
// 改投初始化页,让部署者先把自己的第一个密码设出来。
|
||||
if (!secAdminPassConfigured()) {
|
||||
header('Location: admin_2fa_setup.php');
|
||||
exit;
|
||||
}
|
||||
// ★ 人机验证(2026-10-02):cgWidgetHtml() 渲染挂件,未配密钥时自动放行
|
||||
require_once __DIR__ . '/captcha_guard.php';
|
||||
// ★ 数据安全护栏横幅(2026-10-10):库被清空/指错库时,管理员一登录就能看到告警
|
||||
require_once __DIR__ . '/db_guard.php';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SDF1 - 管理后台登录</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #0d1117; color: #e6edf3;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.login-box {
|
||||
background: #161b22; border: 1px solid #30363d; border-radius: 12px;
|
||||
padding: 40px; width: 380px; max-width: 90%;
|
||||
}
|
||||
.login-box h1 { text-align: center; color: #58a6ff; margin-bottom: 8px; font-size: 24px; }
|
||||
.login-box .sub { text-align: center; color: #8b949e; font-size: 13px; margin-bottom: 28px; }
|
||||
.field { margin-bottom: 16px; }
|
||||
.field label { display: block; font-size: 13px; color: #8b949e; margin-bottom: 6px; }
|
||||
.field input {
|
||||
width: 100%; padding: 10px 14px; background: #0d1117; border: 1px solid #30363d;
|
||||
border-radius: 6px; color: #e6edf3; font-size: 14px; outline: none; transition: border-color 0.2s;
|
||||
}
|
||||
.field input:focus { border-color: #58a6ff; }
|
||||
.login-btn {
|
||||
width: 100%; padding: 12px; background: #58a6ff; color: #fff; border: none;
|
||||
border-radius: 6px; font-size: 15px; font-weight: 600; cursor: pointer;
|
||||
transition: background 0.2s; margin-top: 8px;
|
||||
}
|
||||
.login-btn:hover { background: #79c0ff; }
|
||||
.login-btn:disabled { background: #30363d; cursor: not-allowed; }
|
||||
.error { color: #f85149; font-size: 13px; text-align: center; margin-top: 12px; display: none; }
|
||||
.links { text-align: center; margin-top: 20px; }
|
||||
.links a { color: #58a6ff; text-decoration: none; font-size: 13px; }
|
||||
.links a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-box">
|
||||
<h1>🔐 管理后台</h1>
|
||||
<div class="sub">SDF1 插件管理系统</div>
|
||||
<?php echo function_exists('dfGuardAdminBanner') ? dfGuardAdminBanner() : ''; ?>
|
||||
<div class="field">
|
||||
<label>管理密码</label>
|
||||
<input type="password" id="password" placeholder="请输入管理密码" autofocus>
|
||||
</div>
|
||||
<?php echo cgWidgetHtml(); ?>
|
||||
<button class="login-btn" id="loginBtn" onclick="doLogin()">登 录</button>
|
||||
<div class="error" id="errorMsg"></div>
|
||||
<div class="links"><a href="player.php">← 返回玩家商城</a></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('password').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') doLogin();
|
||||
});
|
||||
|
||||
async function doLogin() {
|
||||
const pw = document.getElementById('password').value;
|
||||
if (!pw) return;
|
||||
document.getElementById('loginBtn').disabled = true;
|
||||
document.getElementById('loginBtn').textContent = '登录中...';
|
||||
|
||||
// ★ 人机验证(2026-10-02):CF 渲染完即有 token(基本无感);VA 没做会在这里拉起验证
|
||||
let cap = {};
|
||||
if (window.CaptchaGuard) {
|
||||
try {
|
||||
cap = await CaptchaGuard.payload();
|
||||
} catch (e) {
|
||||
document.getElementById('errorMsg').textContent = e.message || '请先完成人机验证';
|
||||
document.getElementById('errorMsg').style.display = 'block';
|
||||
document.getElementById('loginBtn').disabled = false;
|
||||
document.getElementById('loginBtn').textContent = '登 录';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('api/admin.php?action=login', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(Object.assign({password: pw}, cap))
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
// ★ 第三层:登录接口在密码正确后才下发入口令牌,用来拼 admin.php?token=
|
||||
const tk = (data.data && data.data.token) ? ('?token=' + encodeURIComponent(data.data.token)) : '';
|
||||
// ★ 第一层:启用二次验证时先进 6 位动态码验证页,通过后再进后台
|
||||
if (data.data && data.data.need_2fa) {
|
||||
window.location.href = 'admin_2fa.php';
|
||||
} else {
|
||||
window.location.href = 'admin.php' + tk;
|
||||
}
|
||||
} else {
|
||||
document.getElementById('errorMsg').textContent = data.message;
|
||||
document.getElementById('errorMsg').style.display = 'block';
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('errorMsg').textContent = '网络错误';
|
||||
document.getElementById('errorMsg').style.display = 'block';
|
||||
}
|
||||
document.getElementById('loginBtn').disabled = false;
|
||||
document.getElementById('loginBtn').textContent = '登 录';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,318 @@
|
||||
<?php
|
||||
/**
|
||||
* 余额查询+充值API
|
||||
* GET ?action=query - 查询余额(需token)
|
||||
* POST ?action=recharge - 充值债券(需token)
|
||||
* GET ?action=transactions - 查看流水(需token)
|
||||
*/
|
||||
// 防止任何输出污染JSON响应
|
||||
ob_start();
|
||||
require_once __DIR__ . '/../core.php';
|
||||
ob_end_clean();
|
||||
|
||||
$action = getParam('action', 'query');
|
||||
$token = getParam('token');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'query':
|
||||
balanceQuery($token);
|
||||
break;
|
||||
case 'recharge':
|
||||
balanceRecharge($token);
|
||||
break;
|
||||
case 'transactions':
|
||||
balanceTransactions($token);
|
||||
break;
|
||||
default:
|
||||
error('未知操作: ' . $action);
|
||||
}
|
||||
|
||||
// ★ WAL checkpoint:释放WAL锁,减少database is locked概率
|
||||
try { walCheckpoint(); } catch (\Throwable $ignored) {}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => ['code' => 500, 'message' => 'Internal error: ' . $e->getMessage()]], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ===== 查询余额 =====
|
||||
function balanceQuery($token) {
|
||||
$player = getParam('player');
|
||||
$password = getParam('password');
|
||||
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
if (!$player) error('缺少player');
|
||||
|
||||
// ★ 安全验证:必须验证token且token对应的玩家必须与查询的玩家匹配
|
||||
$tokenInfo = null;
|
||||
$queriedPlayer = $player; // 最终查询的玩家名
|
||||
|
||||
// 1. 检查是否为管理后台session
|
||||
if (session_status() === PHP_SESSION_NONE) session_start();
|
||||
$isAdmin = isset($_SESSION['admin_auth']) && $_SESSION['admin_auth'];
|
||||
|
||||
if ($isAdmin) {
|
||||
// 管理员:可以查任意玩家
|
||||
$tokenInfo = ['player' => null, 'purpose' => 'admin'];
|
||||
} elseif ($token) {
|
||||
// 2. 验证token并获取token对应的玩家名
|
||||
$tempTokenInfo = validateToken($token);
|
||||
if ($tempTokenInfo) {
|
||||
$tokenPlayer = $tempTokenInfo['player'];
|
||||
$tokenPurpose = $tempTokenInfo['purpose'] ?? '';
|
||||
|
||||
if ($tokenPurpose === 'admin' || $tokenPurpose === 'all') {
|
||||
// 管理员token:可以查任意玩家
|
||||
$tokenInfo = $tempTokenInfo;
|
||||
} elseif ($tokenPlayer === $player) {
|
||||
// 普通token:只能查自己的余额
|
||||
// 进一步验证weblogin token的访问权限
|
||||
$accessResult = validateWebAccess($token, 'view', $password, $ipAddress);
|
||||
if ($accessResult['ok']) {
|
||||
$queriedPlayer = $accessResult['player'];
|
||||
$tokenInfo = ['player' => $queriedPlayer, 'purpose' => 'weblogin'];
|
||||
} elseif ($accessResult['mode'] === 'need_password') {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'need_password' => true,
|
||||
'player' => $accessResult['player'],
|
||||
'message' => $accessResult['message']
|
||||
], 401);
|
||||
} else {
|
||||
error($accessResult['message'], 401);
|
||||
}
|
||||
} else {
|
||||
error('无权查询其他玩家余额', 403);
|
||||
}
|
||||
} else {
|
||||
error('无效token,请先登录', 401);
|
||||
}
|
||||
} else {
|
||||
error('查询余额需要有效token,请先登录', 401);
|
||||
}
|
||||
|
||||
// 查询债券余额(从Web端的债券缓存表)
|
||||
$db = getDB();
|
||||
|
||||
// 先检查是否有缓存表
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS bond_cache (
|
||||
player_name TEXT PRIMARY KEY,
|
||||
amount INTEGER DEFAULT 0,
|
||||
updated_at INTEGER DEFAULT 0
|
||||
)");
|
||||
|
||||
$stmt = $db->prepare("SELECT amount, updated_at FROM bond_cache WHERE player_name = :name");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
// 同时查询积分
|
||||
$stmt2 = $db->prepare("SELECT points FROM users WHERE player_name = :name");
|
||||
$stmt2->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$result2 = $stmt2->execute();
|
||||
$row2 = $result2->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
$bonds = $row ? $row['amount'] : 0;
|
||||
$points = $row2 ? $row2['points'] : 0;
|
||||
$bondsUpdated = $row ? $row['updated_at'] : 0;
|
||||
|
||||
// 计算数据新鲜度
|
||||
$freshness = '';
|
||||
if ($bondsUpdated > 0) {
|
||||
$age = time() - $bondsUpdated;
|
||||
if ($age < 300) $freshness = '刚刚同步';
|
||||
elseif ($age < 3600) $freshness = floor($age / 60) . '分钟前同步';
|
||||
elseif ($age < 86400) $freshness = floor($age / 3600) . '小时前同步';
|
||||
else $freshness = floor($age / 86400) . '天前同步(数据可能过期)';
|
||||
} else {
|
||||
$freshness = '尚未同步';
|
||||
}
|
||||
|
||||
success([
|
||||
'player' => $player,
|
||||
'bonds' => $bonds,
|
||||
'points' => $points,
|
||||
'bonds_updated' => $bondsUpdated,
|
||||
'freshness' => $freshness
|
||||
]);
|
||||
}
|
||||
|
||||
// ===== 充值债券 =====
|
||||
function balanceRecharge($token) {
|
||||
$player = getParam('player');
|
||||
$amount = (int)getParam('amount', 0);
|
||||
$operator = getParam('operator', 'web_admin');
|
||||
|
||||
if (!$player) error('缺少player');
|
||||
if ($amount <= 0) error('充值金额必须大于0');
|
||||
|
||||
// 需要token
|
||||
$tokenInfo = null;
|
||||
$isPreview = true;
|
||||
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo) {
|
||||
$isPreview = false;
|
||||
// 权限检查:只有管理员token才能充值
|
||||
if ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all') {
|
||||
error('充值需要管理员权限');
|
||||
}
|
||||
validateAndUseToken($token);
|
||||
}
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
|
||||
// 确保缓存表存在
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS bond_cache (
|
||||
player_name TEXT PRIMARY KEY,
|
||||
amount INTEGER DEFAULT 0,
|
||||
updated_at INTEGER DEFAULT 0
|
||||
)");
|
||||
|
||||
// 查询当前余额
|
||||
$stmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
$currentBalance = $row ? $row['amount'] : 0;
|
||||
$newBalance = $currentBalance + $amount;
|
||||
|
||||
if ($isPreview) {
|
||||
preview([
|
||||
'player' => $player,
|
||||
'amount' => $amount,
|
||||
'balance_before' => $currentBalance,
|
||||
'balance_after' => $newBalance,
|
||||
'operator' => $operator
|
||||
], '预览模式 - 充值不会生效');
|
||||
}
|
||||
|
||||
// 执行充值
|
||||
if ($row) {
|
||||
$stmt = $db->prepare("UPDATE bond_cache SET amount = :amount, updated_at = :time WHERE player_name = :name");
|
||||
$stmt->bindValue(':amount', $newBalance, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
} else {
|
||||
$stmt = $db->prepare("INSERT INTO bond_cache (player_name, amount, updated_at) VALUES (:name, :amount, :time)");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':amount', $newBalance, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
// 记录流水
|
||||
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, status, created_at) VALUES (:player, 'admin_recharge', :amount, :reason, 'pending', :time)");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':reason', "管理员充值: {$operator}", SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
success([
|
||||
'player' => $player,
|
||||
'amount' => $amount,
|
||||
'balance_before' => $currentBalance,
|
||||
'balance_after' => $newBalance
|
||||
], '充值成功');
|
||||
}
|
||||
|
||||
// ===== 查看流水 =====
|
||||
function balanceTransactions($token) {
|
||||
$player = getParam('player');
|
||||
$limit = (int)getParam('limit', 50);
|
||||
|
||||
// 需要token
|
||||
$tokenInfo = null;
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
}
|
||||
|
||||
if (!$tokenInfo) {
|
||||
error('查看流水需要有效token', 401);
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
|
||||
$txs = [];
|
||||
|
||||
if ($player) {
|
||||
// 查看指定玩家流水
|
||||
if ($tokenInfo['player'] !== $player && $tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all') {
|
||||
error('无权查看其他玩家流水');
|
||||
}
|
||||
// Web端交易
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT *, 'web' as source FROM web_transactions WHERE player_name = :player ORDER BY created_at DESC LIMIT :limit");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (\Throwable $e) { /* ignore */ }
|
||||
// 游戏内交易
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT java_id as id, player_name, type, amount, operator, reason, '' as detail, tx_time as created_at, 'game' as source, balance_before, balance_after FROM game_transactions WHERE player_name = :player ORDER BY tx_time DESC LIMIT :limit");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (\Throwable $e) { /* ignore */ }
|
||||
} else {
|
||||
// 管理员查看全服流水
|
||||
if ($tokenInfo['purpose'] !== 'admin' && $tokenInfo['purpose'] !== 'all') {
|
||||
error('查看全服流水需要管理员权限');
|
||||
}
|
||||
// Web端交易
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT *, 'web' as source FROM web_transactions ORDER BY created_at DESC LIMIT :limit");
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (\Throwable $e) { /* ignore */ }
|
||||
// 游戏内交易
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT java_id as id, player_name, type, amount, operator, reason, '' as detail, tx_time as created_at, 'game' as source, balance_before, balance_after FROM game_transactions ORDER BY tx_time DESC LIMIT :limit");
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) $txs[] = $row;
|
||||
} catch (\Throwable $e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// 按时间排序(倒序)
|
||||
usort($txs, function($a, $b) {
|
||||
return ($b['created_at'] ?? 0) - ($a['created_at'] ?? 0);
|
||||
});
|
||||
$txs = array_slice($txs, 0, (int)$limit);
|
||||
|
||||
success($txs);
|
||||
}
|
||||
|
||||
// ===== Weblogin Token验证(用于API调用) =====
|
||||
function validateWebloginTokenForAPI($webToken) {
|
||||
$db = getDB();
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE web_token = :token");
|
||||
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$row) return false;
|
||||
|
||||
$createdAt = (int)$row['created_at'];
|
||||
$expireSeconds = (int)$row['expire_seconds'];
|
||||
if (time() - $createdAt > $expireSeconds) return false;
|
||||
|
||||
return [
|
||||
'player' => $row['player_name'],
|
||||
'purpose' => 'weblogin',
|
||||
'created_at' => $createdAt,
|
||||
'expires_at' => $createdAt + $expireSeconds
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
/**
|
||||
* 收银台 API(独立页面后端)
|
||||
* 支持:收银员登录/登出/会话、玩家查询、订单列表、收银员管理(仅管理员)
|
||||
*/
|
||||
|
||||
// ★ 清除所有缓冲 + 发送 JSON Content-Type
|
||||
while (ob_get_level() > 0) { @ob_end_clean(); }
|
||||
ob_start();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
ini_set('memory_limit', '256M');
|
||||
|
||||
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
|
||||
|
||||
register_shutdown_function(function() {
|
||||
while (ob_get_level() > 0) {
|
||||
@ob_end_flush();
|
||||
}
|
||||
});
|
||||
|
||||
if (!extension_loaded('sqlite3')) {
|
||||
exit(json_encode(['success' => false, 'message' => 'Missing SQLite3'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../core.php';
|
||||
|
||||
if (!function_exists('error') || !function_exists('success') || !function_exists('getParam')) {
|
||||
exit(json_encode(['success' => false, 'message' => 'core.php load failed'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$action = getParam('action', 'status');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'login': cashierDoLogin(); break;
|
||||
case 'logout': cashierDoLogout(); break;
|
||||
case 'session': cashierSession(); break;
|
||||
case 'player_check': cashierPlayerCheck(); break;
|
||||
case 'order_list': cashierOrderList(); break;
|
||||
case 'cashier_list': cashierList(); break;
|
||||
case 'add': cashierAdd(); break;
|
||||
case 'edit': cashierEdit(); break;
|
||||
case 'delete': cashierDelete(); break;
|
||||
default:
|
||||
exit(json_encode(['success' => false, 'message' => '未知操作: ' . $action], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ★ 2026-07-07 补强:顶层统一捕获 Throwable(含 Error),避免致命错误逃逸导致前端收到
|
||||
// 非 JSON 响应(表现为“加载失败”却无具体原因)。同时落盘 debug.log 便于事后定位。
|
||||
debugLog('[cashier.php] 顶层未捕获异常: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
|
||||
exit(json_encode(['success' => false, 'message' => '服务器错误: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
// ===== 收银员登录 =====
|
||||
function cashierDoLogin() {
|
||||
$username = trim(getParam('username', ''));
|
||||
$password = getParam('password', '');
|
||||
if ($username === '' || $password === '') {
|
||||
exit(json_encode(['success' => false, 'message' => '请输入账号和密码'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
if (cashierLogin($username, $password)) {
|
||||
$c = getCurrentCashier();
|
||||
exit(json_encode([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'username' => $c['username'],
|
||||
'discount_limit_percent' => $c['discount_limit_percent'],
|
||||
'can_cash' => (int)($c['can_cash'] ?? 0)
|
||||
]
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
exit(json_encode(['success' => false, 'message' => '账号或密码错误'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
// ===== 收银员登出 =====
|
||||
function cashierDoLogout() {
|
||||
cashierLogout();
|
||||
exit(json_encode(['success' => true, 'message' => '已退出登录'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
// ===== 当前会话信息 =====
|
||||
function cashierSession() {
|
||||
if (isAdminLoggedIn()) {
|
||||
exit(json_encode([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'role' => 'admin',
|
||||
'username' => 'admin',
|
||||
'discount_limit_percent' => 100
|
||||
]
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
if (isCashierLoggedIn()) {
|
||||
$c = getCurrentCashier();
|
||||
exit(json_encode([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'role' => 'cashier',
|
||||
'username' => $c['username'],
|
||||
'discount_limit_percent' => $c['discount_limit_percent'],
|
||||
'can_cash' => (int)($c['can_cash'] ?? 0)
|
||||
]
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
exit(json_encode(['success' => false, 'message' => '未登录'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
// ===== 查询目标玩家余额/在线状态(收银员或管理员均可)=====
|
||||
function cashierPlayerCheck() {
|
||||
requireCashierOrAdminSession();
|
||||
$player = trim(getParam('player'));
|
||||
if (!$player) exit(json_encode(['success' => false, 'message' => '请输入玩家名'], JSON_UNESCAPED_UNICODE));
|
||||
if (!preg_match('/^[a-zA-Z0-9_]{3,16}$/', $player)) {
|
||||
exit(json_encode(['success' => false, 'message' => '玩家名格式不正确(3-16位英文/数字/下划线)'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
$db = getDB();
|
||||
try {
|
||||
$db->exec("INSERT OR IGNORE INTO bond_cache (player_name, amount, updated_at) VALUES ('" . str_replace("'", "''", $player) . "', 0, " . time() . ")");
|
||||
$stmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
$balance = $row ? (int)$row['amount'] : 0;
|
||||
|
||||
$online = false;
|
||||
try {
|
||||
$ostmt = $db->prepare("SELECT player_name FROM online_players WHERE player_name = :name");
|
||||
$ostmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$orow = $ostmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
$online = $orow ? true : false;
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
exit(json_encode([
|
||||
'success' => true,
|
||||
'data' => ['player' => $player, 'exists' => true, 'balance' => $balance, 'online' => $online]
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
} catch (Exception $e) {
|
||||
@error_log('[cashier.php] Player check error: ' . $e->getMessage());
|
||||
exit(json_encode(['success' => false, 'message' => '查询失败: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 订单列表 =====
|
||||
function cashierOrderList() {
|
||||
$role = requireCashierOrAdminSession();
|
||||
// 收银员只能看自己的订单
|
||||
$c = null;
|
||||
if ($role === 'cashier') {
|
||||
$c = getCurrentCashier();
|
||||
}
|
||||
// ★ 尽早释放会话锁:查询期间不再占用 session 文件,避免阻塞同会话并发请求
|
||||
if (session_status() === PHP_SESSION_ACTIVE) session_write_close();
|
||||
|
||||
// ★ 直接从独立 orders.db 读取:该库 Java 永不读写,彻底脱离 web.db 文件锁竞争,
|
||||
// 因此不再需要 busy_timeout=15000 与 5 次重试兜底 hack —— 干净稳定的单次查询。
|
||||
try {
|
||||
$db = getOrdersDB();
|
||||
} catch (\Throwable $e) {
|
||||
exit(json_encode(['success' => false, 'message' => '订单库不可用: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
$limit = (int)getParam('limit', 100);
|
||||
if ($limit <= 0 || $limit > 500) $limit = 100;
|
||||
$sql = "SELECT * FROM cashier_orders";
|
||||
$params = [];
|
||||
if ($role === 'cashier' && $c) {
|
||||
$sql .= " WHERE operator_name = :op";
|
||||
$params[':op'] = $c['username'];
|
||||
}
|
||||
$sql .= " ORDER BY created_at DESC LIMIT " . (int)$limit;
|
||||
|
||||
$rows = [];
|
||||
try {
|
||||
$stmt = $db->prepare($sql);
|
||||
if (!$stmt) {
|
||||
exit(json_encode(['success' => false, 'message' => '查询失败: ' . $db->lastErrorMsg()], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
foreach ($params as $k => $v) {
|
||||
$stmt->bindValue($k, $v, SQLITE3_TEXT);
|
||||
}
|
||||
$result = $stmt->execute();
|
||||
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
if (!empty($r['items_detail'])) {
|
||||
$r['items_detail'] = json_decode($r['items_detail'], true) ?: [];
|
||||
}
|
||||
$rows[] = $r;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ★ 2026-07-07 加固:任何查询异常都返回合法 JSON,绝不把 PHP 报错文本注入响应
|
||||
// (否则前端 r.json() 失败,订单页表现为“加载失败/报错”)。
|
||||
debugLog('[cashier.php] order_list 查询异常: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
|
||||
debugLog('[cashier.php] order_list 异常堆栈: ' . $e->getTraceAsString());
|
||||
exit(json_encode([
|
||||
'success' => false,
|
||||
'message' => '订单查询异常: ' . $e->getMessage()
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
exit(json_encode(['success' => true, 'data' => $rows], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
// ===== 收银员列表(仅管理员)=====
|
||||
function cashierList() {
|
||||
requireAdminSession();
|
||||
$db = getDB();
|
||||
$result = $db->prepare("SELECT id, username, discount_limit_percent, can_cash, created_at, created_by FROM cashiers ORDER BY id ASC")->execute();
|
||||
$rows = [];
|
||||
while ($r = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$rows[] = $r;
|
||||
}
|
||||
exit(json_encode(['success' => true, 'data' => $rows], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
// ===== 新增收银员(仅管理员)=====
|
||||
function cashierAdd() {
|
||||
requireAdminSession();
|
||||
$username = trim(getParam('username', ''));
|
||||
$password = getParam('password', '');
|
||||
$discountLimit = (int)getParam('discount_limit_percent', 0);
|
||||
$canCash = (int)getParam('can_cash', 0);
|
||||
if ($username === '' || $password === '') {
|
||||
exit(json_encode(['success' => false, 'message' => '账号和密码不能为空'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
if (!preg_match('/^[a-zA-Z0-9_]{3,20}$/', $username)) {
|
||||
exit(json_encode(['success' => false, 'message' => '账号格式不正确(3-20位英文/数字/下划线)'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
if ($discountLimit < 0 || $discountLimit > 100) {
|
||||
exit(json_encode(['success' => false, 'message' => '折扣上限需在0-100之间'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
if ($canCash < 0 || $canCash > 1) {
|
||||
exit(json_encode(['success' => false, 'message' => '现金收款权限需为0或1'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
$db = getDB();
|
||||
// 查重
|
||||
$chk = $db->prepare("SELECT id FROM cashiers WHERE username = :u");
|
||||
$chk->bindValue(':u', $username, SQLITE3_TEXT);
|
||||
if ($chk->execute()->fetchArray(SQLITE3_ASSOC)) {
|
||||
exit(json_encode(['success' => false, 'message' => '账号已存在'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$salt = bin2hex(random_bytes(8));
|
||||
$stmt = $db->prepare("INSERT INTO cashiers (username, password_hash, salt, discount_limit_percent, can_cash, created_at, created_by) VALUES (:u,:h,:s,:d,:c,:t,'admin')");
|
||||
$stmt->bindValue(':u', $username, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':h', $hash, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':s', $salt, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':d', $discountLimit, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':c', $canCash, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
exit(json_encode(['success' => true, 'message' => '收银员创建成功'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
// ===== 编辑收银员(仅管理员):可改密码 / 折扣上限 =====
|
||||
function cashierEdit() {
|
||||
requireAdminSession();
|
||||
$id = (int)getParam('id', 0);
|
||||
if ($id <= 0) exit(json_encode(['success' => false, 'message' => '缺少id'], JSON_UNESCAPED_UNICODE));
|
||||
$db = getDB();
|
||||
$chk = $db->prepare("SELECT id FROM cashiers WHERE id = :id");
|
||||
$chk->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
if (!$chk->execute()->fetchArray(SQLITE3_ASSOC)) {
|
||||
exit(json_encode(['success' => false, 'message' => '收银员不存在'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
$sets = [];
|
||||
$params = [':id' => $id];
|
||||
$newPassword = getParam('password', '');
|
||||
if ($newPassword !== '') {
|
||||
$sets[] = "password_hash = :h";
|
||||
$params[':h'] = password_hash($newPassword, PASSWORD_DEFAULT);
|
||||
$sets[] = "salt = :s";
|
||||
$params[':s'] = bin2hex(random_bytes(8));
|
||||
}
|
||||
if (getParam('discount_limit_percent') !== null && getParam('discount_limit_percent') !== '') {
|
||||
$dl = (int)getParam('discount_limit_percent', 0);
|
||||
if ($dl < 0 || $dl > 100) {
|
||||
exit(json_encode(['success' => false, 'message' => '折扣上限需在0-100之间'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
$sets[] = "discount_limit_percent = :d";
|
||||
$params[':d'] = $dl;
|
||||
}
|
||||
if (getParam('can_cash') !== null && getParam('can_cash') !== '') {
|
||||
$cc = (int)getParam('can_cash', 0);
|
||||
if ($cc < 0 || $cc > 1) {
|
||||
exit(json_encode(['success' => false, 'message' => '现金收款权限需为0或1'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
$sets[] = "can_cash = :c";
|
||||
$params[':c'] = $cc;
|
||||
}
|
||||
if (empty($sets)) {
|
||||
exit(json_encode(['success' => false, 'message' => '没有可更新的字段'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
$sql = "UPDATE cashiers SET " . implode(', ', $sets) . " WHERE id = :id";
|
||||
$stmt = $db->prepare($sql);
|
||||
foreach ($params as $k => $v) {
|
||||
$stmt->bindValue($k, $v, is_int($v) ? SQLITE3_INTEGER : SQLITE3_TEXT);
|
||||
}
|
||||
$stmt->execute();
|
||||
exit(json_encode(['success' => true, 'message' => '收银员信息已更新'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
// ===== 删除收银员(仅管理员)=====
|
||||
function cashierDelete() {
|
||||
requireAdminSession();
|
||||
$id = (int)getParam('id', 0);
|
||||
if ($id <= 0) exit(json_encode(['success' => false, 'message' => '缺少id'], JSON_UNESCAPED_UNICODE));
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("DELETE FROM cashiers WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
exit(json_encode(['success' => true, 'message' => '收银员已删除'], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
<?php
|
||||
/**
|
||||
* CDK兑换API v2 - 20260620
|
||||
* POST ?action=exchange - CDK兑换(需token,由插件请求)
|
||||
* POST ?action=create - 创建CDK(需管理token)
|
||||
* GET ?action=list - CDK列表(需管理token)
|
||||
* POST ?action=delete - 删除CDK(需管理token)
|
||||
* POST ?action=batch - 批量生成CDK(需管理token)
|
||||
*/
|
||||
// 防止任何输出污染JSON响应
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
require_once __DIR__ . '/../core.php';
|
||||
// 再次确保清理前置输出
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
|
||||
$action = getParam('action', 'exchange');
|
||||
$token = getParam('token');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'exchange':
|
||||
cdkExchange($token);
|
||||
break;
|
||||
case 'create':
|
||||
cdkCreate($token);
|
||||
break;
|
||||
case 'list':
|
||||
cdkList($token);
|
||||
break;
|
||||
case 'delete':
|
||||
cdkDelete($token);
|
||||
break;
|
||||
case 'batch':
|
||||
cdkBatch($token);
|
||||
break;
|
||||
default:
|
||||
error('未知操作: ' . $action);
|
||||
}
|
||||
|
||||
// ★ WAL checkpoint:释放WAL锁,减少database is locked概率
|
||||
try { walCheckpoint(); } catch (\Throwable $ignored) {}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => ['code' => 500, 'message' => 'Internal error: ' . $e->getMessage()]], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ===== CDK兑换 =====
|
||||
function cdkExchange($token) {
|
||||
$code = getParam('code');
|
||||
$player = getParam('player');
|
||||
$password = getParam('password');
|
||||
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
|
||||
if (!$code) error('缺少code');
|
||||
if (!$player) error('缺少player');
|
||||
|
||||
// ★ 双保险验证:CDK兑换需要密码确认
|
||||
$isPreview = true;
|
||||
$validated = false;
|
||||
|
||||
if ($token) {
|
||||
// 先检查是否为普通管理token
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
|
||||
$isPreview = false;
|
||||
$validated = true;
|
||||
} else {
|
||||
// weblogin token → 走双保险验证
|
||||
$accessResult = validateWebAccess($token, 'cdk', $password, $ipAddress);
|
||||
if ($accessResult['ok'] || $accessResult['mode'] === 'full_verified') {
|
||||
$isPreview = false;
|
||||
$validated = true;
|
||||
$player = $accessResult['player'];
|
||||
} elseif ($accessResult['mode'] === 'need_password') {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'need_password' => true,
|
||||
'player' => $accessResult['player'],
|
||||
'message' => $accessResult['message']
|
||||
], 401);
|
||||
} else {
|
||||
error($accessResult['message'], 401);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有token也不是管理token,返回预览
|
||||
if (!$validated) {
|
||||
$isPreview = true;
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
|
||||
// ★ 第32批拉黑拦截:本地兑换前先查拉黑名单
|
||||
// (web_player_blacklist 由 Java BlacklistManager 经 sync_blacklist 同步;
|
||||
// 本地CDK存在也一律拒绝,与 Java CDKManager.redeem 的检查口径一致)
|
||||
$blackReason = playerWebBlacklistReason($player);
|
||||
if ($blackReason !== null) {
|
||||
@error_log("[cdk] 拉黑拦截兑换: player={$player}");
|
||||
error('你已被拉黑:' . $blackReason);
|
||||
}
|
||||
|
||||
// 查询CDK
|
||||
$stmt = $db->prepare("SELECT * FROM cdk WHERE code = :code");
|
||||
$stmt->bindValue(':code', strtoupper(trim($code)), SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$cdk = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$cdk) {
|
||||
// ★ 本地没找到 → 写待验证请求,轮询Java Sdf1_login返回结果
|
||||
$cdk = tryRemoteCdkValidation($db, $code, $player);
|
||||
if (!$cdk) {
|
||||
error('CDK不存在: ' . $code);
|
||||
}
|
||||
}
|
||||
|
||||
if ($cdk['used'] == 1) {
|
||||
error('CDK已被使用: ' . $cdk['used_by']);
|
||||
}
|
||||
|
||||
// 查询当前余额
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS bond_cache (
|
||||
player_name TEXT PRIMARY KEY,
|
||||
amount INTEGER DEFAULT 0,
|
||||
updated_at INTEGER DEFAULT 0
|
||||
)");
|
||||
|
||||
$stmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
$currentBalance = $row ? $row['amount'] : 0;
|
||||
$newBalance = $currentBalance + $cdk['amount'];
|
||||
|
||||
if ($isPreview) {
|
||||
preview([
|
||||
'code' => $code,
|
||||
'amount' => $cdk['amount'],
|
||||
'type' => $cdk['type'],
|
||||
'player' => $player,
|
||||
'balance_before' => $currentBalance,
|
||||
'balance_after' => $newBalance,
|
||||
'valid' => true
|
||||
], '预览模式 - CDK兑换不会生效');
|
||||
}
|
||||
|
||||
// 执行兑换
|
||||
$now = time();
|
||||
$isRemote = !empty($cdk['_remote']);
|
||||
|
||||
// ★ 全部DB操作包装在事务中,防止database is locked
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
// 标记CDK已使用(仅本地CDK需要)
|
||||
if (!$isRemote) {
|
||||
$stmt = $db->prepare("UPDATE cdk SET used = 1, used_by = :player, used_at = :time WHERE code = :code");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':code', strtoupper(trim($code)), SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
// ★ PHP只负责记录交易,不更新bond_cache(Java端负责加债券)
|
||||
// 记录流水:远程CDK标记completed(Java在pullWebCdkRequestsAndValidate中已直接加债券)
|
||||
// 本地CDK标记pending(Java通过pullPendingTransactions拉取后处理)
|
||||
$txStatus = $isRemote ? 'completed' : 'pending';
|
||||
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'cdk_redeem', :amount, :reason, :detail, :status, :time)");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':amount', $cdk['amount'], SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':reason', $isRemote ? "CDK远程兑换: {$code}" : "CDK兑换: {$code}", SQLITE3_TEXT);
|
||||
$stmt->bindValue(':detail', json_encode(['code' => $code, 'remote' => $isRemote], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
|
||||
$stmt->bindValue(':status', $txStatus, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
// 只有本地CDK需要sync_requests触发Java立即同步
|
||||
if (!$isRemote) {
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
|
||||
$stmt2 = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
|
||||
$stmt2->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt2->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt2->execute();
|
||||
}
|
||||
|
||||
$db->exec('COMMIT');
|
||||
} catch (\Throwable $e) {
|
||||
try { $db->exec('ROLLBACK'); } catch (\Throwable $e2) {}
|
||||
@error_log("[cdk] 兑换事务失败: " . $e->getMessage());
|
||||
}
|
||||
|
||||
success([
|
||||
'code' => $code,
|
||||
'amount' => $cdk['amount'],
|
||||
'player' => $player,
|
||||
'balance_before' => $currentBalance,
|
||||
'balance_after' => $currentBalance
|
||||
], 'CDK兑换成功,债券将由Java端处理');
|
||||
}
|
||||
|
||||
// ===== 创建CDK =====
|
||||
function cdkCreate($token) {
|
||||
$amount = (int)getParam('amount', 0);
|
||||
$type = getParam('type', 'bond');
|
||||
$code = getParam('code');
|
||||
|
||||
if ($amount <= 0) error('金额必须大于0');
|
||||
|
||||
// 需要管理token
|
||||
$tokenInfo = null;
|
||||
$isPreview = true;
|
||||
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
|
||||
$isPreview = false;
|
||||
validateAndUseToken($token);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$code) {
|
||||
$code = strtoupper(bin2hex(random_bytes(4))); // 8位随机码
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$now = time();
|
||||
|
||||
// 检查是否已存在
|
||||
$stmt = $db->prepare("SELECT 1 FROM cdk WHERE code = :code");
|
||||
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
if ($result->fetchArray()) {
|
||||
error('CDK已存在: ' . $code);
|
||||
}
|
||||
|
||||
if ($isPreview) {
|
||||
preview([
|
||||
'code' => $code,
|
||||
'amount' => $amount,
|
||||
'type' => $type
|
||||
], '预览模式 - CDK创建不会生效');
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("INSERT INTO cdk (code, amount, type, created_at) VALUES (:code, :amount, :type, :time)");
|
||||
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
success(['code' => $code, 'amount' => $amount, 'type' => $type], 'CDK创建成功');
|
||||
}
|
||||
|
||||
// ===== CDK列表 =====
|
||||
function cdkList($token) {
|
||||
$tokenInfo = null;
|
||||
$isPreview = true;
|
||||
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
|
||||
$isPreview = false;
|
||||
}
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$result = $db->query("SELECT code, amount, type, used, used_by, created_at, used_at FROM cdk ORDER BY created_at DESC LIMIT 200");
|
||||
|
||||
$list = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$list[] = $row;
|
||||
}
|
||||
|
||||
if ($isPreview) {
|
||||
preview($list, '预览模式');
|
||||
} else {
|
||||
success($list);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 删除CDK =====
|
||||
function cdkDelete($token) {
|
||||
$code = getParam('code');
|
||||
if (!$code) error('缺少code');
|
||||
|
||||
$tokenInfo = null;
|
||||
$isPreview = true;
|
||||
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
|
||||
$isPreview = false;
|
||||
validateAndUseToken($token);
|
||||
}
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
|
||||
if ($isPreview) {
|
||||
preview(['code' => $code], '预览模式 - 删除不会生效');
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("DELETE FROM cdk WHERE code = :code");
|
||||
$stmt->bindValue(':code', strtoupper(trim($code)), SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
|
||||
success(['code' => $code], 'CDK已删除');
|
||||
}
|
||||
|
||||
// ===== 批量生成CDK =====
|
||||
function cdkBatch($token) {
|
||||
$amount = (int)getParam('amount', 0);
|
||||
$count = (int)getParam('count', 1);
|
||||
$type = getParam('type', 'bond');
|
||||
|
||||
if ($amount <= 0) error('金额必须大于0');
|
||||
if ($count < 1 || $count > 100) error('数量范围: 1-100');
|
||||
|
||||
$tokenInfo = null;
|
||||
$isPreview = true;
|
||||
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
|
||||
$isPreview = false;
|
||||
validateAndUseToken($token);
|
||||
}
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$now = time();
|
||||
$codes = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$code = strtoupper(bin2hex(random_bytes(4)));
|
||||
$codes[] = $code;
|
||||
|
||||
if (!$isPreview) {
|
||||
$stmt = $db->prepare("INSERT INTO cdk (code, amount, type, created_at) VALUES (:code, :amount, :type, :time)");
|
||||
$stmt->bindValue(':code', $code, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
}
|
||||
}
|
||||
|
||||
if ($isPreview) {
|
||||
preview([
|
||||
'codes' => $codes,
|
||||
'amount' => $amount,
|
||||
'count' => $count
|
||||
], '预览模式 - 批量创建不会生效');
|
||||
} else {
|
||||
success([
|
||||
'codes' => $codes,
|
||||
'amount' => $amount,
|
||||
'count' => $count
|
||||
], "成功创建{$count}个CDK");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Weblogin Token验证(用于CDK兑换)=====
|
||||
function validateWebloginTokenForCDK($webToken) {
|
||||
$db = getDB();
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE web_token = :token");
|
||||
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$row) return false;
|
||||
|
||||
$createdAt = (int)$row['created_at'];
|
||||
$expireSeconds = (int)$row['expire_seconds'];
|
||||
if (time() - $createdAt > $expireSeconds) return false;
|
||||
|
||||
return [
|
||||
'player' => $row['player_name'],
|
||||
'purpose' => 'weblogin',
|
||||
'created_at' => $createdAt,
|
||||
'expires_at' => $createdAt + $expireSeconds
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 远程CDK验证:写请求表,轮询Java Sdf1_login结果 =====
|
||||
function tryRemoteCdkValidation($db, $code, $player = '') {
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS cdk_validate_requests (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
player_name TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at INTEGER NOT NULL
|
||||
)");
|
||||
// ★ 迁移:给已有表补 player_name 列
|
||||
$cols = [];
|
||||
$colResult = $db->query("PRAGMA table_info(cdk_validate_requests)");
|
||||
while ($col = $colResult->fetchArray(SQLITE3_ASSOC)) { $cols[] = $col['name']; }
|
||||
if (!in_array('player_name', $cols)) {
|
||||
$db->exec("ALTER TABLE cdk_validate_requests ADD COLUMN player_name TEXT DEFAULT ''");
|
||||
}
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS cdk_validate_results (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
amount INTEGER DEFAULT 0,
|
||||
message TEXT DEFAULT '',
|
||||
used INTEGER DEFAULT 0,
|
||||
used_by TEXT DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
)");
|
||||
|
||||
$requestId = strtoupper(bin2hex(random_bytes(8)));
|
||||
$now = time();
|
||||
|
||||
// ★ 写入待验证请求 — 用BEGIN IMMEDIATE防止database is locked
|
||||
try {
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
$stmt = $db->prepare("INSERT OR REPLACE INTO cdk_validate_requests (request_id, code, player_name, status, created_at) VALUES (:id, :code, :player, 'pending', :time)");
|
||||
$stmt->bindValue(':id', $requestId, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':code', strtoupper(trim($code)), SQLITE3_TEXT);
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$db->exec('COMMIT');
|
||||
} catch (\Throwable $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
@error_log("[cdk] 远程验证写入失败: {$e->getMessage()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
@error_log("[cdk] 远程验证请求: {$requestId} code={$code} player={$player}");
|
||||
|
||||
// ★ 轮询结果(最多8秒,每300ms一次)
|
||||
$result = null;
|
||||
for ($i = 0; $i < 27; $i++) {
|
||||
usleep(300000); // 300ms
|
||||
$stmt = $db->prepare("SELECT * FROM cdk_validate_results WHERE request_id = :id");
|
||||
$stmt->bindValue(':id', $requestId, SQLITE3_TEXT);
|
||||
$rs = $stmt->execute();
|
||||
$result = $rs->fetchArray(SQLITE3_ASSOC);
|
||||
if ($result) break;
|
||||
}
|
||||
|
||||
// 清理请求和结果
|
||||
try {
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
$db->exec("DELETE FROM cdk_validate_requests WHERE request_id = '" . SQLite3::escapeString($requestId) . "'");
|
||||
if ($result) {
|
||||
$db->exec("DELETE FROM cdk_validate_results WHERE request_id = '" . SQLite3::escapeString($requestId) . "'");
|
||||
}
|
||||
$db->exec('COMMIT');
|
||||
} catch (\Throwable $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
}
|
||||
|
||||
if (!$result) {
|
||||
@error_log("[cdk] 远程验证超时: {$requestId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
@error_log("[cdk] 远程验证结果: status={$result['status']} amount={$result['amount']}");
|
||||
|
||||
// ★ 第32批:Java端拉黑拦截信号 → 直接提示被拉黑
|
||||
// (若当 null 返回,上层会误报「CDK不存在」,掩盖真实原因)
|
||||
if ($result['status'] === 'blacklisted') {
|
||||
$blReason = playerWebBlacklistReason($player);
|
||||
@error_log("[cdk] 远程验证 blacklisted: player={$player}");
|
||||
error('你已被拉黑:' . ($blReason !== null ? $blReason : '违反服务器规则'));
|
||||
}
|
||||
|
||||
if ($result['status'] === 'success') {
|
||||
// ★ Java端已标记used并加债券,Web端只做记录不重复加钱
|
||||
return [
|
||||
'code' => $result['code'],
|
||||
'amount' => (int)$result['amount'],
|
||||
'type' => 'bond',
|
||||
'used' => 0,
|
||||
'used_by' => '',
|
||||
'created_at' => $now,
|
||||
'used_at' => 0,
|
||||
'_remote' => true
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../security.php'; // 第四层:全局限流(2026-09-30)
|
||||
require_once __DIR__ . '/../inc/function.php';
|
||||
|
||||
/** @var PDO $db */
|
||||
function getPlayerList() {
|
||||
global $db;
|
||||
$stmt = $db->prepare("SELECT player_name FROM users");
|
||||
$stmt->execute();
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$players = [];
|
||||
foreach ($rows as $row) {
|
||||
$players[] = $row['player_name'];
|
||||
}
|
||||
exit(json_encode(['success' => true, 'data' => $players], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
if (!isLogin()) error('请先登录', 401);
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') error('方法不允许');
|
||||
getPlayerList();
|
||||
@@ -0,0 +1,346 @@
|
||||
<?php
|
||||
/**
|
||||
* 收银台订单前缀 / 订单号生成 —— 统一读配置的公共小工具(2026-10-06 创建,2026-10-07 改读 pay.md)
|
||||
*
|
||||
* 背景:平台 MySQL 的 pay_order 是多商户共用表,创单与三个补单脚本此前都把前缀
|
||||
* 写死在各自代码里。生产服 / 测试服共用一个前缀就会互相捡单 —— 测试服
|
||||
* 补单器把生产服的已支付单补进测试库,反过来也一样(2026-10-06 串数据的根因)。
|
||||
*
|
||||
* ★ 2026-10-07 订单号规则改读站点根目录 pay.md(配置+文档二合一文件):
|
||||
* - 只认「### 以下是配置区域:」后第一个 ```json``` 块,其余说明区全部忽略;
|
||||
* - 「前缀码」= 完整订单前缀(创单与补单永远一致),payOrderPrefix() 的第一来源;
|
||||
* - 「是否固定订单号格式」= true → 用「订单号格式」模板:[]去掉、纯数字的 0 换随机;
|
||||
* 模板非纯数字(或为空)→ 回退保底 6 位递增序列 000001/000002…(计数器持久化在
|
||||
* db/pay_order_seq.txt,flock 防并发撞单);
|
||||
* - = false → 按「当前配置」组合规则生成(+/- 连接符不进订单号,验证码位数读
|
||||
* 「订单验证码长度」字段而非文本里的数字);
|
||||
* - pay.md 缺失 / 解析失败 → 一律回退旧版逻辑(前缀+时间+4码+玩家名),行为与改造前一致。
|
||||
*
|
||||
* 前缀兼容(防串单,2026-10-06 事故修复的延续):
|
||||
* - payOrderPrefix() 第一来源 pay.md「前缀码」→ 回退 config.php 的 PAY_ORDER_PREFIX
|
||||
* → 回退 'RE',只保留字母数字(要拼进 SQL LIKE,与平台侧约定一致);
|
||||
* - payOrderIsOurs() 认「当前前缀 + 历史前缀(PAY_ORDER_PREFIX)」任一,改前缀后
|
||||
* 历史在途单仍被补单器/查询接口认领;
|
||||
* - payOrderPrefixLikeAny() 给补单器用的双前缀 SQL 条件(poller_online /
|
||||
* recharge_orders_api / pay_poller),扫描范围与 payOrderIsOurs 一致。
|
||||
*
|
||||
* ★ 本文件不依赖 core.php,任何脚本(含自包含的 poller)都能直接 require。
|
||||
* ★ 可用 define('PAY_MD_PATH', '/path/pay.md') 覆盖 pay.md 路径(仅测试用)。
|
||||
* ★ 债券换算(estimateBonds)不在本文件:金额→债券由后台「充值商店配置」
|
||||
* (shop_configs.bond_reward) 决定,代码不干预比例。
|
||||
*/
|
||||
|
||||
if (!function_exists('payMdOrderConfig')) {
|
||||
/**
|
||||
* 读 pay.md「### 以下是配置区域:」之后的第一个 ```json``` 块。
|
||||
* 二合一文件里说明区的表格 / 文本一律忽略。
|
||||
*
|
||||
* @return array|null 解析成功返回关联数组;缺失/损坏返回 null(调用方回退旧逻辑)
|
||||
*/
|
||||
function payMdOrderConfig() {
|
||||
$path = defined('PAY_MD_PATH') ? PAY_MD_PATH : dirname(__DIR__) . '/pay.md';
|
||||
if (!is_file($path)) return null;
|
||||
$md = @file_get_contents($path);
|
||||
if (!is_string($md) || $md === '') return null;
|
||||
$pos = strpos($md, '以下是配置区域');
|
||||
if ($pos === false) return null;
|
||||
if (!preg_match('/```(?:json|JSON)?\s*(.*?)```/s', substr($md, $pos), $m)) return null;
|
||||
$cfg = json_decode(trim($m[1]), true);
|
||||
return is_array($cfg) ? $cfg : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('payOrderPrefix')) {
|
||||
/**
|
||||
* 订单号前缀(永不返回空串)—— 第一来源 pay.md「前缀码」。
|
||||
* pay.md 缺失/未配/解析失败时回退 config.php 的 PAY_ORDER_PREFIX,再回退 'RE'
|
||||
* (与 2026-10-06 改造前的行为完全一致,线上老配置零变化)。
|
||||
* 只保留字母数字:它要拼进 SQL 的 LIKE '前缀%',也与平台侧约定一致。
|
||||
*/
|
||||
function payOrderPrefix() {
|
||||
$p = '';
|
||||
$cfg = payMdOrderConfig();
|
||||
if (is_array($cfg) && isset($cfg['前缀码'])) {
|
||||
$p = trim((string)$cfg['前缀码']);
|
||||
}
|
||||
if ($p === '') {
|
||||
$p = defined('PAY_ORDER_PREFIX') ? trim((string)PAY_ORDER_PREFIX) : '';
|
||||
}
|
||||
if ($p === '') return 'RE';
|
||||
$p = preg_replace('/[^A-Za-z0-9]/', '', $p);
|
||||
return $p === '' ? 'RE' : $p;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('payOrderPrefixes')) {
|
||||
/**
|
||||
* 本端全部有效前缀 = [当前前缀(pay.md), 历史前缀(PAY_ORDER_PREFIX,不同时才带上)]。
|
||||
* 用于「认旧单」:改前缀后,改动前已生成、尚未补单的订单仍能被本端认领。
|
||||
* @return string[]
|
||||
*/
|
||||
function payOrderPrefixes() {
|
||||
$list = array(payOrderPrefix());
|
||||
$legacy = defined('PAY_ORDER_PREFIX') ? preg_replace('/[^A-Za-z0-9]/', '', trim((string)PAY_ORDER_PREFIX)) : '';
|
||||
if ($legacy !== '' && !in_array($legacy, $list, true)) {
|
||||
$list[] = $legacy;
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('payOrderPrefixLen')) {
|
||||
/** 前缀长度(strncmp 用) */
|
||||
function payOrderPrefixLen() {
|
||||
return strlen(payOrderPrefix());
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('payOrderIsOurs')) {
|
||||
/**
|
||||
* 判断一个 out_trade_no 是否本端订单(补单器/查询接口的兜底校验)
|
||||
* 当前前缀与历史前缀命中其一即算本端单。
|
||||
* @param string $outNo
|
||||
* @return bool
|
||||
*/
|
||||
function payOrderIsOurs($outNo) {
|
||||
foreach (payOrderPrefixes() as $p) {
|
||||
if (strncmp((string)$outNo, $p, strlen($p)) === 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('payOrderPrefixLike')) {
|
||||
/** SQL 片段:out_trade_no LIKE '当前前缀%'(单前缀,向后兼容保留) */
|
||||
function payOrderPrefixLike() {
|
||||
return payOrderPrefix() . '%';
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('payOrderPrefixLikeAny')) {
|
||||
/**
|
||||
* SQL 条件片段:out_trade_no LIKE '当前前缀%' OR out_trade_no LIKE '历史前缀%'。
|
||||
* 补单器扫描用,与 payOrderIsOurs 的认定范围一致;前缀均已净化为字母数字,无需转义。
|
||||
* 用法:WHERE (".payOrderPrefixLikeAny().") AND status IN (1,2)
|
||||
* @return string
|
||||
*/
|
||||
function payOrderPrefixLikeAny() {
|
||||
$conds = array();
|
||||
foreach (payOrderPrefixes() as $p) {
|
||||
$conds[] = "out_trade_no LIKE '" . $p . "%'";
|
||||
}
|
||||
return implode(' OR ', $conds);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('payRandomCode')) {
|
||||
/**
|
||||
* 随机校验码:长度 1~32,去掉易混的 I/O/0/1
|
||||
* @param int $len
|
||||
* @return string
|
||||
*/
|
||||
function payRandomCode($len) {
|
||||
$len = (int)$len;
|
||||
if ($len < 1) $len = 4;
|
||||
if ($len > 32) $len = 32;
|
||||
$cs = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
$out = '';
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$out .= $cs[mt_rand(0, strlen($cs) - 1)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('payComposeOrderNo')) {
|
||||
/**
|
||||
* 按「当前配置」组合规则拼订单号。连接符(+/-/+/-)只作分隔,不进订单号。
|
||||
* 变量映射(中文变量名与说明区英文别名都认):
|
||||
* 剦缀码/front → 当前前缀;当前时间/下单时间/timeis → YmdHis;
|
||||
* 订单验证码/校验码/ordcode → 随机码(长度读「订单验证码长度」,不读文本里的"N位");
|
||||
* 玩家名字/用户名/username → 净化后的玩家名;未知变量忽略。
|
||||
*
|
||||
* @param string $tpl 「当前配置」原文,如 剦缀码+当前时间+4位订单验证码+玩家名字
|
||||
* @param int $codeLen 验证码长度
|
||||
* @param string $name 已净化的玩家名
|
||||
* @return array|null [前段, 后段, 是否含玩家名](订单号=前段.玩家名.后段);无有效段返回 null
|
||||
*/
|
||||
function payComposeOrderNo($tpl, $codeLen, $name) {
|
||||
$tokens = preg_split('/[+\-\x{FF0B}\x{FF0D}]/u', (string)$tpl);
|
||||
if (!is_array($tokens) || count($tokens) === 0) return null;
|
||||
$pre = ''; // 玩家名之前的段落
|
||||
$post = ''; // 玩家名之后的段落
|
||||
$withName = false; // 组合里含玩家名变量(payGuardLen 据此裁剪)
|
||||
$afterName = false; // 已越过玩家名位置
|
||||
$valid = false;
|
||||
foreach ($tokens as $t) {
|
||||
$t = trim($t);
|
||||
if ($t === '') continue;
|
||||
$tl = strtolower($t);
|
||||
if (strpos($t, '前缀') !== false || $tl === 'front') {
|
||||
$seg = payOrderPrefix();
|
||||
} elseif (strpos($t, '时间') !== false || strpos($t, '日期') !== false || $tl === 'timeis') {
|
||||
$seg = date('YmdHis');
|
||||
} elseif (strpos($t, '验证码') !== false || strpos($t, '校验码') !== false || $tl === 'ordcode') {
|
||||
$seg = payRandomCode($codeLen);
|
||||
} elseif (!$afterName && !$withName
|
||||
&& (strpos($t, '玩家') !== false || strpos($t, '用户') !== false || $tl === 'username')) {
|
||||
// 玩家名段:本身由 payGuardLen 统一拼装,这里只切换分段状态(重复的玩家名变量忽略)
|
||||
$withName = true;
|
||||
$afterName = true;
|
||||
$valid = true;
|
||||
continue;
|
||||
} else {
|
||||
continue; // 未知变量忽略
|
||||
}
|
||||
if ($afterName) $post .= $seg; else $pre .= $seg;
|
||||
$valid = true;
|
||||
}
|
||||
if (!$valid) return null;
|
||||
return array($pre, $post, $withName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('payMakeFixedOrderNo')) {
|
||||
/**
|
||||
* 固定订单号格式分支:前缀码 + 订单号格式模板。
|
||||
* 模板 [] 去掉;
|
||||
* - 纯数字(如 00000000)→ 按【模板位数】递增序列:00000001、00000002…
|
||||
* (计数器持久化在 db/pay_order_seq.txt,flock 防撞单;当前无订单则从 1 开始);
|
||||
* - 非纯数字或为空 → 保底 6 位递增序列 000001、000002…。
|
||||
* @param array $cfg pay.md 配置区
|
||||
* @return string
|
||||
*/
|
||||
function payMakeFixedOrderNo($cfg) {
|
||||
$p = payOrderPrefix();
|
||||
$tpl = isset($cfg['订单号格式']) ? (string)$cfg['订单号格式'] : '';
|
||||
$tpl = str_replace(array('[', ']', "\u{FF3B}", "\u{FF3D}", ' ', "\t"), '', $tpl);
|
||||
if ($tpl !== '' && ctype_digit($tpl)) {
|
||||
// ★ 纯数字模板:按模板位数递增序列(00000000 → CYZJ00000001, CYZJ00000002…)
|
||||
$no = payMakeSequentialOrderNo($tpl);
|
||||
} else {
|
||||
// 非纯数字模板(或空)→ 保底 6 位递增序列
|
||||
$no = payMakeSequentialOrderNo('000000');
|
||||
}
|
||||
$max = defined('PAY_ORDER_MAX_LEN') ? (int)PAY_ORDER_MAX_LEN : 64;
|
||||
if ($max > 0 && strlen($no) > $max) $no = substr($no, 0, $max);
|
||||
return $no;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('payMakeSequentialOrderNo')) {
|
||||
/**
|
||||
* 递增序列订单号:前缀 + 模板位数的序列(000001、000002…,默认 6 位)。
|
||||
* 计数器持久化在 db/pay_order_seq.txt(flock 排他锁读写改,防并发撞单);
|
||||
* 计数器不可写时退化为「前缀+日期时间+3位随机」,保底不与历史单撞号。
|
||||
*
|
||||
* @param string $tpl 位数模板(纯数字,如 '00000000'),决定序列补零位数
|
||||
* @return string
|
||||
*/
|
||||
function payMakeSequentialOrderNo($tpl = '000000') {
|
||||
$p = payOrderPrefix();
|
||||
$width = strlen(ctype_digit($tpl) ? $tpl : '000000');
|
||||
$dir = dirname(__DIR__) . '/db';
|
||||
if (!is_dir($dir)) @mkdir($dir, 0755, true);
|
||||
$file = $dir . '/pay_order_seq.txt';
|
||||
$n = 0;
|
||||
$fp = @fopen($file, 'c+');
|
||||
if ($fp !== false) {
|
||||
if (flock($fp, LOCK_EX)) {
|
||||
$raw = trim((string)stream_get_contents($fp));
|
||||
$n = (is_numeric($raw) && (int)$raw > 0) ? (int)$raw : 0;
|
||||
$n++;
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, (string)$n);
|
||||
fflush($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
}
|
||||
fclose($fp);
|
||||
}
|
||||
if ($n <= 0) {
|
||||
return $p . date('ymdHis') . str_pad((string)mt_rand(0, 999), 3, '0', STR_PAD_LEFT);
|
||||
}
|
||||
return $p . str_pad((string)$n, $width, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('payGuardLen')) {
|
||||
/**
|
||||
* 长度护栏:平台 out_trade_no 默认 varchar(64)。
|
||||
* 订单号 = $pre . 玩家名 . $post($withName=false 时玩家名不参与)。
|
||||
* 超长时先裁玩家名段,其次整体裁尾(前缀在头部绝不被裁 —— 前缀一旦被截,
|
||||
* 补单器就再也认不出这单了)。
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
function payGuardLen($pre, $post, $name, $withName) {
|
||||
$max = defined('PAY_ORDER_MAX_LEN') ? (int)PAY_ORDER_MAX_LEN : 64;
|
||||
if ($max <= 0) return $withName ? $pre . $name . $post : $pre . $post;
|
||||
if (!$withName || $name === '') {
|
||||
$no = $pre . $post;
|
||||
return strlen($no) > $max ? substr($no, 0, $max) : $no;
|
||||
}
|
||||
$fixed = strlen($pre) + strlen($post);
|
||||
$allow = $max - $fixed;
|
||||
if ($allow >= strlen($name)) {
|
||||
return $pre . $name . $post;
|
||||
}
|
||||
if ($allow > 0) {
|
||||
return $pre . substr($name, 0, $allow) . $post;
|
||||
}
|
||||
// 极端情况(前缀配得极长):先退回不含玩家名的形态,仍超长则整体裁尾保前缀
|
||||
$no = $pre . $post;
|
||||
return strlen($no) > $max ? substr($no, 0, $max) : $no;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('payMakeOrderNo')) {
|
||||
/**
|
||||
* 生成本端订单号(2026-10-07 改读 pay.md 配置区)
|
||||
*
|
||||
* 分支:
|
||||
* 1) 「是否固定订单号格式」= true → payMakeFixedOrderNo(模板纯数字0换随机 /
|
||||
* 非纯数字回退 6 位递增序列),不再读组合规则;
|
||||
* 2) = false(或 pay.md 缺失/解析失败)→ 按「当前配置」组合;组合规则缺失时
|
||||
* 回退旧版默认(前缀 + 年月日时分秒 + 4位随机校验码 + 玩家名,
|
||||
* 例:CYZJ20261006171418Q101youpaishidifu),行为与改造前完全一致。
|
||||
*
|
||||
* 玩家名只保留 [A-Za-z0-9_](Minecraft 用户名规则),其余字符剔除;
|
||||
* 剔除后为空则整段省略,保证前缀/时间戳/校验码绝不被破坏。
|
||||
*
|
||||
* @param string $playerName 下单玩家名
|
||||
* @return string
|
||||
*/
|
||||
function payMakeOrderNo($playerName = '') {
|
||||
$cfg = payMdOrderConfig();
|
||||
|
||||
// 玩家名净化:只留字母数字下划线
|
||||
$name = preg_replace('/[^A-Za-z0-9_]/', '', (string)$playerName);
|
||||
if ($name === '') $name = '';
|
||||
|
||||
// —— 分支 1:固定订单号格式 ——
|
||||
if (is_array($cfg) && !empty($cfg['是否固定订单号格式'])) {
|
||||
return payMakeFixedOrderNo($cfg);
|
||||
}
|
||||
|
||||
// —— 分支 2:按「当前配置」组合规则 ——
|
||||
if (is_array($cfg) && isset($cfg['当前配置']) && trim((string)$cfg['当前配置']) !== '') {
|
||||
$codeLen = 4;
|
||||
if (isset($cfg['订单验证码长度'])) {
|
||||
$l = (int)$cfg['订单验证码长度'];
|
||||
if ($l >= 1 && $l <= 32) $codeLen = $l;
|
||||
}
|
||||
$r = payComposeOrderNo(trim((string)$cfg['当前配置']), $codeLen, $name);
|
||||
if ($r !== null && ($r[0] . $r[1]) !== '') {
|
||||
return payGuardLen($r[0], $r[1], $name, $r[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// —— 回退:旧版默认组合(pay.md 缺失/解析失败/无有效规则)——
|
||||
$p = payOrderPrefix();
|
||||
$stamp = date('YmdHis'); // 14位:20261006171418
|
||||
$code = payRandomCode(4); // 4位校验码(去掉易混的 I/O/0/1)
|
||||
return payGuardLen($p . $stamp . $code, '', $name, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../security.php'; // 第四层:全局限流(2026-09-30)
|
||||
/**
|
||||
* 支付补单器(poller_online.php)— 绕过 Cloudflare WAF 拦截的 HTTP 回调通知
|
||||
*
|
||||
* 工作原理:
|
||||
* 直接读平台 MySQL 中已支付订单(status IN (1,2)) → 与本地 SQLite(pay_orders) 比对 →
|
||||
* 将未处理的订单写入 web_transactions(recharge, pending) → 供 Java 高频定时器拉取 → addBonds 到账
|
||||
*
|
||||
* 触发方式(双保险):
|
||||
* 1. 玩家在 Web 端查询订单时,pay.php 的 query_order 异步调用本脚本(前端触发)
|
||||
* 2. Java 定时拉取 web_transactions 前,先调用本脚本(后台定时触发,最可靠)
|
||||
*
|
||||
* 完全自包含,不依赖 core.php,避免 500 错误。幂等:重复跑不会重复写流水。
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
|
||||
// 只在直接执行时清除输出缓冲区,被包含时保留
|
||||
if (!defined('POLLER_NO_AUTO_RUN')) {
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
}
|
||||
|
||||
// ===== 配置(从 pay_secrets.php 加载,集中管理) =====
|
||||
$secretsFile = __DIR__ . '/pay_secrets.php';
|
||||
if (@is_file($secretsFile) && @is_readable($secretsFile)) {
|
||||
@require_once $secretsFile;
|
||||
}
|
||||
|
||||
// ★ 订单前缀读配置(2026-10-06 多服隔离):创单端 pay.php 用什么前缀,这里就扫什么前缀。
|
||||
// 此前写死 'RE',测试服与生产服共用一个前缀 → 互相捡单串数据。
|
||||
// ★★ 改前缀时,pay.php / poller_online.php / pay_poller.php / recharge_orders_api.php
|
||||
// 四个文件必须一起改(它们现在都读同一个 PAY_ORDER_PREFIX,只要 config.php 改了就同步生效)。
|
||||
require_once __DIR__ . '/pay_config.php';
|
||||
$PLATFORM_DB_HOST = defined('PAY_MYSQL_HOST') ? PAY_MYSQL_HOST : '127.0.0.1';
|
||||
$PLATFORM_DB_NAME = defined('PAY_MYSQL_DBNAME') ? PAY_MYSQL_DBNAME : 'caihong';
|
||||
$PLATFORM_DB_USER = defined('PAY_MYSQL_USER') ? PAY_MYSQL_USER : 'hbye3AezRNk4r7YA';
|
||||
$PLATFORM_DB_PASS = defined('PAY_MYSQL_PASS') ? PAY_MYSQL_PASS : '5HtD7Rn3seRAn2BE';
|
||||
$GLOBALS['PLATFORM_DB_PREFIX'] = 'pay_'; $PLATFORM_DB_PREFIX = 'pay_';
|
||||
|
||||
// SQLite 数据库路径
|
||||
$GLOBALS['SQLITE_DB_PATH'] = sqlDbFile('java'); // web_transactions (Java专属表 → Java.db)
|
||||
$GLOBALS['ORDERS_DB_PATH'] = __DIR__ . '/../db/orders.db'; // pay_orders (PHP读写)
|
||||
|
||||
if (!function_exists('debugLog')) {
|
||||
function debugLog($msg, $ctx = []) {
|
||||
$logFile = __DIR__ . '/../db/debug.log';
|
||||
$ts = date('Y-m-d H:i:s');
|
||||
$entry = "[$ts] $msg";
|
||||
if ($ctx) $entry .= ' | Context: ' . json_encode($ctx, JSON_UNESCAPED_UNICODE);
|
||||
$entry .= "\n";
|
||||
@file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX);
|
||||
// ★ 保留策略(2026-10-05):只留最近3天 + ≤5MB,任一超标即清理到两条件同时满足
|
||||
if (is_file(__DIR__ . '/../log_maintain.php')) {
|
||||
@require_once __DIR__ . '/../log_maintain.php';
|
||||
if (function_exists('logMaintain')) { @logMaintain($logFile); }
|
||||
}
|
||||
}
|
||||
} // end if (!function_exists('debugLog'))
|
||||
|
||||
function getSQLite() {
|
||||
// web.db — Java 高频读写(web_transactions 表)
|
||||
static $db = null;
|
||||
if ($db === null) {
|
||||
$db = new SQLite3($GLOBALS['SQLITE_DB_PATH']);
|
||||
$db->enableExceptions(true);
|
||||
$db->exec('PRAGMA journal_mode=WAL');
|
||||
$db->exec('PRAGMA busy_timeout=5000');
|
||||
// web_transactions 表(Java 读取补单)
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_name TEXT,
|
||||
type TEXT,
|
||||
amount INTEGER,
|
||||
operator TEXT,
|
||||
reason TEXT,
|
||||
detail TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at INTEGER
|
||||
)");
|
||||
@$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_web_tx_detail_type ON web_transactions(detail, type)");
|
||||
}
|
||||
return $db;
|
||||
}
|
||||
|
||||
function getOrdersSQLite() {
|
||||
// orders.db — PHP 订单库(pay_orders 表),独立于 Java 的 web.db
|
||||
static $db = null;
|
||||
if ($db === null) {
|
||||
$db = new SQLite3($GLOBALS['ORDERS_DB_PATH']);
|
||||
$db->enableExceptions(true);
|
||||
$db->exec('PRAGMA journal_mode=WAL');
|
||||
$db->exec('PRAGMA busy_timeout=5000');
|
||||
// pay_orders 表
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS pay_orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
out_trade_no TEXT UNIQUE,
|
||||
trade_no TEXT,
|
||||
player_name TEXT NOT NULL,
|
||||
tier_id INTEGER DEFAULT 0,
|
||||
money TEXT,
|
||||
bond_amount INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'created',
|
||||
name TEXT,
|
||||
platform_sign TEXT,
|
||||
submit_params TEXT,
|
||||
created_at INTEGER,
|
||||
paid_at INTEGER
|
||||
)");
|
||||
}
|
||||
return $db;
|
||||
}
|
||||
|
||||
function getPlatformDB() {
|
||||
try {
|
||||
// ★ 直接用常量(从 pay_secrets.php 加载),不依赖全局变量
|
||||
$host = defined('PAY_MYSQL_HOST') ? PAY_MYSQL_HOST : '127.0.0.1';
|
||||
$dbname = defined('PAY_MYSQL_DBNAME') ? PAY_MYSQL_DBNAME : 'caihong';
|
||||
$user = defined('PAY_MYSQL_USER') ? PAY_MYSQL_USER : 'hbye3AezRNk4r7YA';
|
||||
$pass = defined('PAY_MYSQL_PASS') ? PAY_MYSQL_PASS : '5HtD7Rn3seRAn2BE';
|
||||
|
||||
// ★ 2026-10-05 日志收敛:连接过程(正在连接/DSN/连接成功)属于每次轮询的
|
||||
// 常规路径,一律不写日志——补单器约 20 秒跑一次,这 3 行是 debug.log
|
||||
// 膨胀的元凶之一。只有下面 catch 的"连接失败"(报错)才写日志。
|
||||
$start = microtime(true);
|
||||
$dsn = "mysql:host=$host;dbname=$dbname;charset=utf8mb4";
|
||||
$pdo = new PDO($dsn, $user, $pass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_TIMEOUT => 15,
|
||||
]);
|
||||
return $pdo;
|
||||
} catch (\Throwable $e) {
|
||||
$elapsed = round((microtime(true) - ($start ?? microtime(true))) * 1000);
|
||||
debugLog("[poller] MySQL连接失败", [
|
||||
'error' => $e->getMessage(),
|
||||
'code' => $e->getCode(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'elapsed_ms'=> $elapsed,
|
||||
'dsn' => $dsn ?? 'undefined',
|
||||
'user' => $user ?? 'undefined',
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 债券换算(仅当本地 pay_orders 无 bond_amount 时作为兜底估算)
|
||||
* 0.01元 → 1债券;>=1元 → 金额*10债券
|
||||
*/
|
||||
function estimateBonds($money) {
|
||||
$m = (float)$money;
|
||||
if ($m >= 1.0) return (int)($m * 10);
|
||||
return max(1, (int)($m * 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* 进程锁:防止多个触发源(Java 定时、前端 query_order、管理员手动"立即对账")
|
||||
* 并发跑补单造成重复写入 / 资源争用。非阻塞,若已被占用则跳过本次。
|
||||
* 锁文件独立存在,进程退出后内核自动释放。
|
||||
*/
|
||||
function pollerAcquireLock() {
|
||||
static $fp = null;
|
||||
if ($fp === null) {
|
||||
$lockPath = __DIR__ . '/../db/poller.lock';
|
||||
$fp = @fopen($lockPath, 'c');
|
||||
}
|
||||
if ($fp === false) return false;
|
||||
return flock($fp, LOCK_EX | LOCK_NB);
|
||||
}
|
||||
|
||||
function pollPaidOrders() {
|
||||
global $PLATFORM_DB_PREFIX;
|
||||
$startTime = microtime(true);
|
||||
// ★ 2026-10-05 日志收敛:不再无条件写"补单开始"。没有合适订单时下面直接
|
||||
// 静默 return(只回 JSON,不写日志);只有真正补到单(成功)或出错才写。
|
||||
|
||||
// 并发保护:已有补单进程在跑则直接跳过,杜绝重复补单
|
||||
if (!pollerAcquireLock()) {
|
||||
echo json_encode(['result' => 'already_running', 'detail' => '另一个补单进程正在运行,本次跳过']);
|
||||
return;
|
||||
}
|
||||
|
||||
$sqlite = getOrdersSQLite(); // orders.db(pay_orders)
|
||||
$webSqlite = getSQLite(); // web.db(web_transactions)
|
||||
|
||||
try {
|
||||
$pdo = getPlatformDB();
|
||||
} catch (\Throwable $e) {
|
||||
debugLog('[poller_online] 平台数据库连接失败', [
|
||||
'error' => $e->getMessage(),
|
||||
'code' => $e->getCode(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'context' => 'pollPaidOrders',
|
||||
]);
|
||||
echo json_encode([
|
||||
'error' => 'platform_db_connect_failed',
|
||||
'detail' => $e->getMessage(),
|
||||
'code' => $e->getCode(),
|
||||
'file' => basename($e->getFile()),
|
||||
'line' => $e->getLine(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 2026-10-02 只查后端自己的订单:平台 MySQL 是多商户共用库,`pay_order` 里
|
||||
// 绝大多数是其他商户/项目的单(纯数字单号)。以前不加过滤整表拉,平台每来一单
|
||||
// 就被我们"捡走"、凭空写进本地 pay_orders + web_transactions,产生玩家=unknown
|
||||
// 的垃圾充值单(Java 每次拉交易都刷屏处理它们),还顺手把别人的订单 notify
|
||||
// 改成 1 —— 纯属多管闲事。本方单号由 pay.php createOrder 生成,前缀取自
|
||||
// config.php 的 PAY_ORDER_PREFIX(生产 'RE' / 测试服 'RT' 等,各部署各配),
|
||||
// 平台侧据此过滤,PHP 侧再兜底校验一次;平台其他订单一概不读不写。
|
||||
// 仍不过滤 notify:平台可能已标记 notify=1 但本地 DB 未更新(HTTP 回调被
|
||||
// CF WAF 拦截),是否补单以本地 pay_orders 状态为准。
|
||||
$prefix = $PLATFORM_DB_PREFIX;
|
||||
// ★ 2026-10-07 改为【定向补单】:不再按前缀批量扫平台共享库,
|
||||
// 而是先取本地 pay_orders 里所有未补单(非 paid)的【完整订单号】,
|
||||
// 再让平台只按这些完整订单号精确匹配。这样就算生产/测试共用 CYZJ 前缀,
|
||||
// 测试服脚本也只会查它自己本地活跃的订单号,抓不到生产服的单。
|
||||
$activeStmt = $sqlite->prepare("SELECT out_trade_no FROM pay_orders WHERE status != 'paid' AND out_trade_no IS NOT NULL AND out_trade_no != ''");
|
||||
// ★ 2026-10-08 实测修正:SQLite3Stmt::execute() 直接返回 SQLite3Result(PHP 7.4/8.2/8.5
|
||||
// 三个版本实测一致),Stmt/Result 上都没有 getResult() 方法。此前一版写
|
||||
// $activeStmt->fetchArray()(Stmt 无此方法→致命错误)、上一版写 getResult()
|
||||
// (方法不存在→致命错误),补单器其实一直没跑起来过。
|
||||
$activeRes = $activeStmt->execute();
|
||||
$activeNos = array();
|
||||
while ($activeRes && ($a = $activeRes->fetchArray(SQLITE3_ASSOC)) !== false) {
|
||||
$activeNos[] = (string)$a['out_trade_no'];
|
||||
}
|
||||
if (empty($activeNos)) {
|
||||
echo json_encode(['result' => 'no_local_active_orders']);
|
||||
return;
|
||||
}
|
||||
// 平台侧按完整订单号精确匹配(IN 子句,最多取前 200 个,足够覆盖并发场景)
|
||||
$activeNos = array_slice($activeNos, 0, 200);
|
||||
$inList = "'" . implode("','", array_map('addslashes', $activeNos)) . "'";
|
||||
$stmt = $pdo->prepare("SELECT out_trade_no, trade_no, uid, money, status, notify, param, version, addtime FROM `{$prefix}order` WHERE out_trade_no IN (" . $inList . ") AND status IN (1, 2)");
|
||||
$stmt->execute();
|
||||
$allOrders = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($allOrders)) {
|
||||
echo json_encode(['result' => 'no_paid_orders_on_platform', 'active_local' => count($activeNos)]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 只保留「本地确实在跟踪、且还没补单」的订单(二次过滤,防本地已被补单/已支付)
|
||||
$orders = [];
|
||||
foreach ($allOrders as $o) {
|
||||
$outNo = (string)$o['out_trade_no'];
|
||||
if (!in_array($outNo, $activeNos, true)) {
|
||||
continue;
|
||||
}
|
||||
$check = $sqlite->prepare("SELECT status FROM pay_orders WHERE out_trade_no = :no");
|
||||
$check->bindValue(':no', $outNo, SQLITE3_TEXT);
|
||||
$checkRes = $check->execute();
|
||||
$row = $checkRes ? $checkRes->fetchArray(SQLITE3_ASSOC) : false;
|
||||
if (!$row || $row['status'] !== 'paid') {
|
||||
$orders[] = $o;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($orders)) {
|
||||
echo json_encode(['result' => 'no_pending_orders', 'active_local' => count($activeNos)]);
|
||||
return;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$skipped = 0;
|
||||
$now = time();
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$outTradeNo = $order['out_trade_no'];
|
||||
$tradeNo = $order['trade_no'];
|
||||
$playerName = $order['param'] ?? 'unknown';
|
||||
$money = (string)$order['money'];
|
||||
|
||||
// 查本地 pay_orders(拿到正确的 bond_amount,创建订单时按档位设置)
|
||||
$check = $sqlite->prepare("SELECT status, bond_amount FROM pay_orders WHERE out_trade_no = :no");
|
||||
$check->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
|
||||
$checkRes = $check->execute();
|
||||
$row = $checkRes ? $checkRes->fetchArray(SQLITE3_ASSOC) : false;
|
||||
|
||||
if ($row && $row['status'] === 'paid') {
|
||||
$skipped++;
|
||||
markNotified($pdo, $tradeNo);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 债券数:优先用本地订单记录的 bond_amount(最准确),否则估算
|
||||
$bonds = 0;
|
||||
if ($row && !empty($row['bond_amount'])) {
|
||||
$bonds = (int)$row['bond_amount'];
|
||||
} else {
|
||||
$bonds = estimateBonds($money);
|
||||
}
|
||||
|
||||
debugLog('[poller_online] 补单处理', [
|
||||
'out_trade_no' => $outTradeNo,
|
||||
'player' => $playerName,
|
||||
'money' => $money,
|
||||
'bonds' => $bonds,
|
||||
'platform_notify'=> $order['notify'],
|
||||
'has_local_order'=> $row ? 1 : 0,
|
||||
]);
|
||||
|
||||
// 写/更新本地 pay_orders
|
||||
if (!$row) {
|
||||
$ins = $sqlite->prepare("INSERT OR IGNORE INTO pay_orders (out_trade_no, trade_no, player_name, money, bond_amount, status, created_at, paid_at) VALUES (:no, :tn, :p, :m, :b, 'paid', :t, :t)");
|
||||
$ins->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
|
||||
$ins->bindValue(':tn', $tradeNo, SQLITE3_TEXT);
|
||||
$ins->bindValue(':p', $playerName, SQLITE3_TEXT);
|
||||
$ins->bindValue(':m', $money, SQLITE3_TEXT);
|
||||
$ins->bindValue(':b', $bonds, SQLITE3_INTEGER);
|
||||
$ins->bindValue(':t', $now, SQLITE3_INTEGER);
|
||||
$ins->execute();
|
||||
} else {
|
||||
$upd = $sqlite->prepare("UPDATE pay_orders SET status='paid', trade_no=:tn, paid_at=:t, bond_amount=:b WHERE out_trade_no=:no AND status != 'paid'");
|
||||
$upd->bindValue(':tn', $tradeNo, SQLITE3_TEXT);
|
||||
$upd->bindValue(':t', $now, SQLITE3_INTEGER);
|
||||
$upd->bindValue(':b', $bonds, SQLITE3_INTEGER);
|
||||
$upd->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
|
||||
$upd->execute();
|
||||
}
|
||||
|
||||
// 写 web_transactions(幂等:INSERT OR IGNORE 防止竞态条件重复插入)→ web.db
|
||||
$txIns = $webSqlite->prepare("INSERT OR IGNORE INTO web_transactions (player_name, type, amount, operator, reason, detail, status, created_at) VALUES (:p, 'recharge', :a, '支付平台(补单)', '在线充值(支付宝)', :d, 'pending', :t)");
|
||||
$txIns->bindValue(':p', $playerName, SQLITE3_TEXT);
|
||||
$txIns->bindValue(':a', $bonds, SQLITE3_INTEGER);
|
||||
$txIns->bindValue(':d', $outTradeNo, SQLITE3_TEXT);
|
||||
$txIns->bindValue(':t', $now, SQLITE3_INTEGER);
|
||||
$txIns->execute();
|
||||
|
||||
// 检查是否是新插入的(affected_rows > 0 表示新插入,=0 表示已存在被忽略)
|
||||
if ($webSqlite->changes() > 0) {
|
||||
debugLog('[poller_online] 充值交易已写入 web_transactions', [
|
||||
'player' => $playerName, 'bonds' => $bonds,
|
||||
'out_trade_no' => $outTradeNo, 'money' => $money,
|
||||
]);
|
||||
}
|
||||
|
||||
markNotified($pdo, $tradeNo);
|
||||
$processed++;
|
||||
}
|
||||
|
||||
$elapsed = round((microtime(true) - $startTime) * 1000);
|
||||
// ★ 2026-10-05 日志收敛:只在本轮真正补到单(processed > 0)时写"补单完成";
|
||||
// 没有实际补单动作则静默,不再每轮刷一条。
|
||||
if ($processed > 0) {
|
||||
debugLog('[poller] 补单完成', ['processed' => $processed, 'skipped' => $skipped, 'elapsed_ms' => $elapsed]);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'result' => 'ok',
|
||||
'processed' => $processed,
|
||||
'skipped' => $skipped,
|
||||
'foreign_skipped' => 0,
|
||||
'total' => count($orders),
|
||||
'elapsed_ms' => $elapsed,
|
||||
]);
|
||||
}
|
||||
|
||||
function markNotified($pdo, $tradeNo) {
|
||||
global $PLATFORM_DB_PREFIX;
|
||||
try {
|
||||
$prefix = $PLATFORM_DB_PREFIX;
|
||||
$stmt = $pdo->prepare("UPDATE {$prefix}order SET notify = 1 WHERE trade_no = ? AND notify = 0");
|
||||
$stmt->execute([$tradeNo]);
|
||||
} catch (\Throwable $e) {
|
||||
debugLog('[poller_online] 标记notify失败: ' . $e->getMessage(), ['trade_no' => $tradeNo]);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 入口 =====
|
||||
// 若被其它脚本 include 并定义了 POLLER_NO_AUTO_RUN,则只暴露函数(getPlatformDB 等),
|
||||
// 不在此处自动执行补单(由调用方自行决定何时触发)。
|
||||
if (!defined('POLLER_NO_AUTO_RUN')) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
try {
|
||||
pollPaidOrders();
|
||||
} catch (\Throwable $e) {
|
||||
debugLog('[poller_online] 未捕获异常', ['error' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine()]);
|
||||
echo json_encode(['error' => 'uncaught_exception', 'detail' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
<?php
|
||||
/**
|
||||
* 充值订单API(recharge_orders_api.php)
|
||||
* 提供充值订单查询、统计、筛选功能
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0);
|
||||
ob_start();
|
||||
require_once __DIR__ . '/../core.php';
|
||||
ob_end_clean();
|
||||
|
||||
// 加载支付密钥(含MySQL凭据)
|
||||
$secretsFile = __DIR__ . '/pay_secrets.php';
|
||||
if (@is_file($secretsFile) && @is_readable($secretsFile)) {
|
||||
@require_once $secretsFile;
|
||||
}
|
||||
|
||||
// ★ 订单前缀读配置(2026-10-06 多服隔离):与 pay.php 创单端共用同一份 PAY_ORDER_PREFIX
|
||||
require_once __DIR__ . '/pay_config.php';
|
||||
|
||||
/**
|
||||
* 获取平台MySQL数据库连接(集中凭据管理)
|
||||
* 注意:poller_online.php 也有同名函数,此处用不同名称避免 redeclare 冲突
|
||||
*/
|
||||
function getAdminPlatformDB() {
|
||||
$host = defined('PAY_MYSQL_HOST') ? PAY_MYSQL_HOST : '127.0.0.1';
|
||||
$dbname = defined('PAY_MYSQL_DBNAME') ? PAY_MYSQL_DBNAME : 'caihong';
|
||||
$user = defined('PAY_MYSQL_USER') ? PAY_MYSQL_USER : 'hbye3AezRNk4r7YA';
|
||||
$pass = defined('PAY_MYSQL_PASS') ? PAY_MYSQL_PASS : '5HtD7Rn3seRAn2BE';
|
||||
$pdo = new PDO(
|
||||
"mysql:host=$host;dbname=$dbname;charset=utf8mb4",
|
||||
$user,
|
||||
$pass,
|
||||
[PDO::ATTR_TIMEOUT => 15, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保充值商店配置表存在
|
||||
*/
|
||||
function ensureShopConfigsTable($db) {
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS shop_configs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_name TEXT NOT NULL DEFAULT '',
|
||||
stock INTEGER NOT NULL DEFAULT -1,
|
||||
temporary_offer TEXT NOT NULL DEFAULT '',
|
||||
offer_expire TEXT NOT NULL DEFAULT '',
|
||||
price REAL NOT NULL DEFAULT 0,
|
||||
discount_price REAL NOT NULL DEFAULT 0,
|
||||
bond_reward TEXT NOT NULL DEFAULT '1-1',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
)");
|
||||
// 兼容旧表:检查并添加discount_price字段
|
||||
$hasCol = false;
|
||||
$cols = $db->query("PRAGMA table_info(shop_configs)");
|
||||
while ($col = $cols->fetchArray(SQLITE3_ASSOC)) {
|
||||
if ($col['name'] === 'discount_price') { $hasCol = true; break; }
|
||||
}
|
||||
if (!$hasCol) {
|
||||
$db->exec("ALTER TABLE shop_configs ADD COLUMN discount_price REAL NOT NULL DEFAULT 0");
|
||||
}
|
||||
}
|
||||
|
||||
// 检查管理员登录
|
||||
if (!isAdminLoggedIn()) {
|
||||
error('无权限访问', 403);
|
||||
}
|
||||
|
||||
$action = getParam('action', '');
|
||||
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
handleOrderList();
|
||||
break;
|
||||
case 'detail':
|
||||
handleOrderDetail();
|
||||
break;
|
||||
case 'shop_config':
|
||||
handleShopConfig();
|
||||
break;
|
||||
case 'reconcile':
|
||||
handleReconcile();
|
||||
break;
|
||||
case 'sync_platform':
|
||||
handleSyncFromPlatform();
|
||||
break;
|
||||
default:
|
||||
error('未知操作');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订单列表
|
||||
*/
|
||||
function handleOrderList() {
|
||||
// ★ 使用独立订单库,不和Java共享web.db
|
||||
$db = getOrdersDB();
|
||||
ensurePayOrdersTable($db);
|
||||
|
||||
// ★ 增量迁移:从 web.db 同步缺失的 pay_orders 到 orders.db(处理历史数据)
|
||||
try {
|
||||
$webDb = getDB();
|
||||
$tableCheck = $webDb->query("SELECT name FROM sqlite_master WHERE type='table' AND name='pay_orders'");
|
||||
if ($tableCheck->fetchArray()) {
|
||||
$migrateData = $webDb->query("SELECT * FROM pay_orders");
|
||||
$ins = $db->prepare("INSERT OR IGNORE INTO pay_orders (out_trade_no, trade_no, player_name, tier_id, money, bond_amount, status, name, platform_sign, submit_params, created_at, paid_at) VALUES (:no, :tn, :p, :ti, :m, :b, :s, :n, :ps, :sp, :ca, :pa)");
|
||||
$migrated = 0;
|
||||
while ($row = $migrateData->fetchArray(SQLITE3_ASSOC)) {
|
||||
$ins->bindValue(':no', $row['out_trade_no'] ?? '', SQLITE3_TEXT);
|
||||
$ins->bindValue(':tn', $row['trade_no'] ?? '', SQLITE3_TEXT);
|
||||
$ins->bindValue(':p', $row['player_name'] ?? '', SQLITE3_TEXT);
|
||||
$ins->bindValue(':ti', $row['tier_id'] ?? 0, SQLITE3_INTEGER);
|
||||
$ins->bindValue(':m', $row['money'] ?? '0', SQLITE3_TEXT);
|
||||
$ins->bindValue(':b', $row['bond_amount'] ?? 0, SQLITE3_INTEGER);
|
||||
$ins->bindValue(':s', $row['status'] ?? 'created', SQLITE3_TEXT);
|
||||
$ins->bindValue(':n', $row['name'] ?? '', SQLITE3_TEXT);
|
||||
$ins->bindValue(':ps', $row['platform_sign'] ?? '', SQLITE3_TEXT);
|
||||
$ins->bindValue(':sp', $row['submit_params'] ?? '', SQLITE3_TEXT);
|
||||
$ins->bindValue(':ca', $row['created_at'] ?? 0, SQLITE3_INTEGER);
|
||||
$ins->bindValue(':pa', $row['paid_at'] ?? 0, SQLITE3_INTEGER);
|
||||
$ins->execute();
|
||||
if ($db->changes() > 0) $migrated++;
|
||||
}
|
||||
if ($migrated > 0) {
|
||||
debugLog('[handleOrderList] 增量迁移web.db→orders.db', ['migrated' => $migrated]);
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
debugLog('[handleOrderList] 迁移失败', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
$player = getParam('player', '');
|
||||
$orderNo = getParam('order_no', '');
|
||||
$status = getParam('status', '');
|
||||
$limit = min(200, max(1, (int)getParam('limit', 50)));
|
||||
|
||||
// 构建查询
|
||||
$conditions = [];
|
||||
$params = [];
|
||||
|
||||
if ($player) {
|
||||
$conditions[] = "player_name LIKE :player";
|
||||
$params[':player'] = '%' . $player . '%';
|
||||
}
|
||||
if ($orderNo) {
|
||||
$conditions[] = "out_trade_no LIKE :order_no";
|
||||
$params[':order_no'] = '%' . $orderNo . '%';
|
||||
}
|
||||
if ($status) {
|
||||
$conditions[] = "status = :status";
|
||||
$params[':status'] = $status;
|
||||
}
|
||||
|
||||
$where = '';
|
||||
if ($conditions) {
|
||||
$where = 'WHERE ' . implode(' AND ', $conditions);
|
||||
}
|
||||
|
||||
// 查询订单
|
||||
$sql = "SELECT * FROM pay_orders $where ORDER BY created_at DESC LIMIT :limit";
|
||||
$stmt = $db->prepare($sql);
|
||||
foreach ($params as $k => $v) {
|
||||
$stmt->bindValue($k, $v, SQLITE3_TEXT);
|
||||
}
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
|
||||
$orders = [];
|
||||
$result = $stmt->execute();
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$orders[] = $row;
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
$statsSql = "SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status='paid' THEN 1 ELSE 0 END) as paid,
|
||||
SUM(CASE WHEN status='created' THEN 1 ELSE 0 END) as created,
|
||||
SUM(CASE WHEN status='paid' THEN CAST(money AS REAL) ELSE 0 END) as totalMoney
|
||||
FROM pay_orders";
|
||||
$statsResult = $db->querySingle($statsSql, true);
|
||||
|
||||
$stats = [
|
||||
'total' => $statsResult['total'] ?? 0,
|
||||
'paid' => $statsResult['paid'] ?? 0,
|
||||
'created' => $statsResult['created'] ?? 0,
|
||||
'totalMoney' => number_format($statsResult['totalMoney'] ?? 0, 2),
|
||||
];
|
||||
|
||||
success(['orders' => $orders, 'stats' => $stats]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个订单详情
|
||||
*/
|
||||
function handleOrderDetail() {
|
||||
$outTradeNo = getParam('out_trade_no');
|
||||
if (!$outTradeNo) {
|
||||
error('缺少订单号');
|
||||
}
|
||||
|
||||
// ★ 使用独立订单库
|
||||
$db = getOrdersDB();
|
||||
ensurePayOrdersTable($db);
|
||||
|
||||
$stmt = $db->prepare("SELECT * FROM pay_orders WHERE out_trade_no = :no");
|
||||
$stmt->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
|
||||
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
error('订单不存在');
|
||||
}
|
||||
|
||||
success(['order' => $row]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发对账(调用 poller_online.php)
|
||||
*/
|
||||
function handleReconcile() {
|
||||
// 直接包含 poller_online.php 并调用 pollPaidOrders
|
||||
define('POLLER_NO_AUTO_RUN', true);
|
||||
|
||||
// ★ 清空所有已有缓冲区(防止任何HTML/警告污染JSON)
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
|
||||
// 包含 poller_online.php(定义函数,不执行)
|
||||
require_once __DIR__ . '/poller_online.php';
|
||||
|
||||
// 记录对账开始
|
||||
if (function_exists('debugLog')) {
|
||||
debugLog('[reconcile] 开始对账', [
|
||||
'admin' => $_SESSION['admin_user'] ?? 'unknown',
|
||||
'time' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
// 开始对账 - 捕获所有输出
|
||||
ob_start();
|
||||
try {
|
||||
pollPaidOrders();
|
||||
} catch (\Throwable $e) {
|
||||
// 捕获异常,输出JSON错误
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'poller_exception', 'detail' => $e->getMessage()], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$output = ob_get_clean();
|
||||
|
||||
// 记录原始输出
|
||||
if (function_exists('debugLog')) {
|
||||
debugLog('[reconcile] pollPaidOrders输出', [
|
||||
'output_len' => strlen($output),
|
||||
'output' => substr($output, 0, 500),
|
||||
]);
|
||||
}
|
||||
|
||||
// 解析结果
|
||||
$result = json_decode($output, true);
|
||||
if (!$result || !is_array($result)) {
|
||||
if (function_exists('debugLog')) {
|
||||
debugLog('[reconcile] JSON解析失败', ['output' => substr($output, 0, 200)]);
|
||||
}
|
||||
// 确保输出干净的JSON
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'poller_output_invalid', 'raw' => substr($output, 0, 100)], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 记录解析结果
|
||||
if (function_exists('debugLog')) {
|
||||
debugLog('[reconcile] 解析结果', [
|
||||
'result' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
// 直接输出结果
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($result, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从平台MySQL同步所有已支付订单到本地
|
||||
*/
|
||||
function handleSyncFromPlatform() {
|
||||
// ★ 使用独立订单库,不和Java共享web.db(Java高频写入会锁死SQLite)
|
||||
$db = getOrdersDB();
|
||||
ensurePayOrdersTable($db);
|
||||
// 注意:web_transactions 表只在 web.db 中(Java读取),不在 orders.db 中
|
||||
|
||||
try {
|
||||
$pdo = getAdminPlatformDB();
|
||||
|
||||
// ★ 2026-10-02 只查后端自己的订单:平台 MySQL 是多商户共用库,整表拉 200 条
|
||||
// 会把其他商户/项目的单(纯数字单号)也同步成本地充值单,凭空造出
|
||||
// 玩家=unknown 的垃圾订单。本方单号前缀取自 config.php 的 PAY_ORDER_PREFIX
|
||||
// (pay.php createOrder 用同一个值生成),平台侧据此过滤,PHP 侧再兜底校验;
|
||||
// 平台其他订单一概不碰。
|
||||
// ★ 2026-10-06:前缀改为读配置(原来写死 'RE',测试服改前缀后这里不跟着改
|
||||
// 就会把生产服的单同步进来)。
|
||||
// ★ 2026-10-07:前缀第一来源改为站点根 pay.md「前缀码」;扫描改为「当前前缀+历史前缀」
|
||||
// 双条件(payOrderPrefixLikeAny),改过前缀码后在途老单仍能同步、不丢单。
|
||||
$stmt = $pdo->prepare("SELECT out_trade_no, trade_no, uid, money, status, param, addtime FROM `pay_order` WHERE (" . payOrderPrefixLikeAny() . ") AND status IN (1, 2) ORDER BY addtime DESC LIMIT 200");
|
||||
$stmt->execute();
|
||||
$platformOrders = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$synced = 0;
|
||||
$alreadyPaid = 0;
|
||||
$foreign = 0;
|
||||
$errors = [];
|
||||
$now = time();
|
||||
|
||||
foreach ($platformOrders as $po) {
|
||||
$outTradeNo = $po['out_trade_no'];
|
||||
// ★ 兜底校验:与创单端读同一份 PAY_ORDER_PREFIX
|
||||
if (!payOrderIsOurs($outTradeNo)) {
|
||||
$foreign++; // 非本方订单:不建单、不写流水
|
||||
continue;
|
||||
}
|
||||
$player = $po['param'] ?? 'unknown';
|
||||
$tradeNo = $po['trade_no'] ?? '';
|
||||
$money = (string)$po['money'];
|
||||
// ★ 债券换算保持原始行为(2026-10-06 用户指示:代码不干预换算比例,
|
||||
// 金额→券数由后台「面板-充值商店配置」决定,运维自行调整)。
|
||||
// 本行仅在本地无档位记录时作兜底估算,公式与改造前完全一致。
|
||||
$bonds = ($money >= '1.0') ? (int)($money * 10) : max(1, (int)($money * 100));
|
||||
|
||||
// 查本地是否已有
|
||||
$check = $db->prepare("SELECT id, status FROM pay_orders WHERE out_trade_no = :no");
|
||||
$check->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
|
||||
$localRow = $check->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if ($localRow && $localRow['status'] === 'paid') {
|
||||
$alreadyPaid++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($localRow) {
|
||||
// 已有记录但未标记paid → 更新
|
||||
$upd = $db->prepare("UPDATE pay_orders SET status='paid', trade_no=:tn, paid_at=:t WHERE out_trade_no=:no AND status != 'paid'");
|
||||
$upd->bindValue(':tn', $tradeNo, SQLITE3_TEXT);
|
||||
$upd->bindValue(':t', $now, SQLITE3_INTEGER);
|
||||
$upd->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
|
||||
$upd->execute();
|
||||
} else {
|
||||
// 本地无记录 → 创建
|
||||
$ins = $db->prepare("INSERT OR IGNORE INTO pay_orders (out_trade_no, trade_no, player_name, money, bond_amount, status, name, submit_params, created_at, paid_at) VALUES (:no, :tn, :p, :m, :b, 'paid', :n, '', :t, :t)");
|
||||
$ins->bindValue(':no', $outTradeNo, SQLITE3_TEXT);
|
||||
$ins->bindValue(':tn', $tradeNo, SQLITE3_TEXT);
|
||||
$ins->bindValue(':p', $player, SQLITE3_TEXT);
|
||||
$ins->bindValue(':m', $money, SQLITE3_TEXT);
|
||||
$ins->bindValue(':b', $bonds, SQLITE3_INTEGER);
|
||||
$ins->bindValue(':n', '债券充值', SQLITE3_TEXT);
|
||||
$ins->bindValue(':t', $now, SQLITE3_INTEGER);
|
||||
$ins->execute();
|
||||
}
|
||||
|
||||
// 写 web_transactions(幂等)— 注意:web_transactions 在 web.db 中(Java读取),不在 orders.db
|
||||
try {
|
||||
$webDb = getDB(); // 获取 web.db 连接
|
||||
ensureWebTransactionsTable($webDb);
|
||||
$txCheck = $webDb->prepare("SELECT id FROM web_transactions WHERE detail = :d AND type = 'recharge' LIMIT 1");
|
||||
$txCheck->bindValue(':d', $outTradeNo, SQLITE3_TEXT);
|
||||
$txRow = $txCheck->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$txRow) {
|
||||
$txIns = $webDb->prepare("INSERT OR IGNORE INTO web_transactions (player_name, type, amount, operator, reason, detail, status, created_at) VALUES (:p, 'recharge', :a, '管理员同步', '在线充值(支付宝)', :d, 'pending', :t)");
|
||||
$txIns->bindValue(':p', $player, SQLITE3_TEXT);
|
||||
$txIns->bindValue(':a', $bonds, SQLITE3_INTEGER);
|
||||
$txIns->bindValue(':d', $outTradeNo, SQLITE3_TEXT);
|
||||
$txIns->bindValue(':t', $now, SQLITE3_INTEGER);
|
||||
$txIns->execute();
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// web_transactions 写入失败不影响主流程(poller 会兜底)
|
||||
debugLog('[sync_platform] web_transactions写入失败', ['out_trade_no' => $outTradeNo, 'error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
$synced++;
|
||||
}
|
||||
|
||||
success([
|
||||
'platform_total' => count($platformOrders),
|
||||
'synced' => $synced,
|
||||
'already_paid' => $alreadyPaid,
|
||||
'foreign_skipped' => $foreign,
|
||||
], "同步完成:平台{$synced}笔已同步,{$alreadyPaid}笔已存在,{$foreign}笔非本方订单已忽略");
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
error('同步失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 web_transactions 表存在
|
||||
*/
|
||||
function ensureWebTransactionsTable($db) {
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_name TEXT NOT NULL,
|
||||
type TEXT,
|
||||
amount INTEGER DEFAULT 0,
|
||||
operator TEXT,
|
||||
reason TEXT,
|
||||
detail TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at INTEGER
|
||||
)");
|
||||
@$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_web_tx_detail_type ON web_transactions(detail, type)");
|
||||
}
|
||||
function ensurePayOrdersTable($db) {
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS pay_orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
out_trade_no TEXT UNIQUE,
|
||||
trade_no TEXT,
|
||||
player_name TEXT NOT NULL,
|
||||
tier_id INTEGER DEFAULT 0,
|
||||
money TEXT,
|
||||
bond_amount INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'created',
|
||||
name TEXT,
|
||||
platform_sign TEXT,
|
||||
submit_params TEXT,
|
||||
created_at INTEGER,
|
||||
paid_at INTEGER
|
||||
)");
|
||||
|
||||
// 检查并添加缺失的列
|
||||
$hasCol = false;
|
||||
$res = $db->query("PRAGMA table_info(pay_orders)");
|
||||
while ($col = $res->fetchArray(SQLITE3_ASSOC)) {
|
||||
if ($col['name'] === 'submit_params') { $hasCol = true; break; }
|
||||
}
|
||||
if (!$hasCol) {
|
||||
$db->exec("ALTER TABLE pay_orders ADD COLUMN submit_params TEXT");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理充值商店配置
|
||||
*/
|
||||
function handleShopConfig() {
|
||||
$db = getOrdersDB();
|
||||
ensureShopConfigsTable($db);
|
||||
|
||||
$method = getParam('method', 'list');
|
||||
|
||||
switch ($method) {
|
||||
case 'list':
|
||||
handleShopConfigList($db);
|
||||
break;
|
||||
case 'save':
|
||||
handleShopConfigSave($db);
|
||||
break;
|
||||
case 'delete':
|
||||
handleShopConfigDelete($db);
|
||||
break;
|
||||
case 'toggle':
|
||||
handleShopConfigToggle($db);
|
||||
break;
|
||||
default:
|
||||
error('未知操作');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商店配置列表
|
||||
*/
|
||||
function handleShopConfigList($db) {
|
||||
$stmt = $db->query("SELECT * FROM shop_configs ORDER BY id DESC");
|
||||
$configs = [];
|
||||
while ($row = $stmt->fetchArray(SQLITE3_ASSOC)) {
|
||||
// 解析有效期
|
||||
$expireInfo = parseExpireDate($row['offer_expire']);
|
||||
$row['expire_valid'] = $expireInfo['valid'];
|
||||
$row['expire_timestamp'] = $expireInfo['timestamp'];
|
||||
$row['expire_display'] = $expireInfo['display'];
|
||||
$configs[] = $row;
|
||||
}
|
||||
success($configs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存商店配置(添加或编辑)
|
||||
*/
|
||||
function handleShopConfigSave($db) {
|
||||
$id = intval(getParam('id', 0));
|
||||
$itemName = getParam('item_name', '');
|
||||
$stock = intval(getParam('stock', -1));
|
||||
$temporaryOffer = getParam('temporary_offer', '');
|
||||
$offerExpire = getParam('offer_expire', '');
|
||||
$price = floatval(getParam('price', 0));
|
||||
$discountPriceRaw = getParam('discount_price', '0');
|
||||
$bondReward = getParam('bond_reward', '1-1');
|
||||
$isActive = intval(getParam('is_active', 1));
|
||||
|
||||
// 验证库存
|
||||
if ($stock < -1) {
|
||||
error('库存不能小于-1');
|
||||
}
|
||||
|
||||
// 验证售价
|
||||
if ($price <= 0) {
|
||||
error('售价必须大于0');
|
||||
}
|
||||
|
||||
// 解析优惠后价格
|
||||
// 支持两种格式:
|
||||
// 1. 直接价格(如 1、2、1.5)→ 直接使用
|
||||
// 2. 折扣(如 0.9、0.8)→ 计算 原价 × 折扣
|
||||
// 硬锁规则:
|
||||
// - 0.01元以下的商品不支持折扣
|
||||
// - 优惠后价格必须小于原价
|
||||
$discountPrice = 0;
|
||||
$discountPriceRaw = trim($discountPriceRaw);
|
||||
|
||||
// 0.01元以下的商品不支持折扣
|
||||
if ($price < 0.01) {
|
||||
if ($discountPriceRaw !== '' && $discountPriceRaw !== '0') {
|
||||
error('售价低于0.01元的商品不支持折扣');
|
||||
}
|
||||
} elseif ($discountPriceRaw !== '' && $discountPriceRaw !== '0') {
|
||||
$discountValue = floatval($discountPriceRaw);
|
||||
if ($discountValue <= 0) {
|
||||
error('优惠后价格必须大于0');
|
||||
}
|
||||
|
||||
// 判断是直接价格还是折扣(<1的数认为是折扣)
|
||||
if ($discountValue < 1) {
|
||||
// 折扣:原价 × 折扣
|
||||
$discountPrice = round($price * $discountValue, 2);
|
||||
} else {
|
||||
// 直接价格
|
||||
$discountPrice = round($discountValue, 2);
|
||||
}
|
||||
|
||||
// 硬锁:优惠后价格必须小于原价
|
||||
if ($discountPrice >= $price) {
|
||||
error('优惠后价格必须小于原价(当前原价: ' . number_format($price, 2) . '元)');
|
||||
}
|
||||
|
||||
// 硬锁:优惠后价格不能低于0.01元
|
||||
if ($discountPrice < 0.01) {
|
||||
error('优惠后价格不能低于0.01元');
|
||||
}
|
||||
}
|
||||
|
||||
// 验证债券范围格式(支持盲盒范围,如"1-10")
|
||||
if (!preg_match('/^\d+(-\d+)?$/', $bondReward)) {
|
||||
error('债券范围格式错误,应为数字或范围如"1-10"');
|
||||
}
|
||||
|
||||
// 解析并验证有效期
|
||||
$expireInfo = parseExpireDate($offerExpire);
|
||||
if ($offerExpire && !$expireInfo['valid']) {
|
||||
error('有效期格式错误,请使用yyyy-MM-dd或中文格式如"2026年7月16日"');
|
||||
}
|
||||
|
||||
$now = time();
|
||||
|
||||
if ($id > 0) {
|
||||
// 编辑
|
||||
$stmt = $db->prepare("UPDATE shop_configs SET
|
||||
item_name = :name,
|
||||
stock = :stock,
|
||||
temporary_offer = :offer,
|
||||
offer_expire = :expire,
|
||||
price = :price,
|
||||
discount_price = :discount_price,
|
||||
bond_reward = :bond,
|
||||
is_active = :active,
|
||||
updated_at = :updated
|
||||
WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
} else {
|
||||
// 添加
|
||||
$stmt = $db->prepare("INSERT INTO shop_configs
|
||||
(item_name, stock, temporary_offer, offer_expire, price, discount_price, bond_reward, is_active, created_at, updated_at)
|
||||
VALUES (:name, :stock, :offer, :expire, :price, :discount_price, :bond, :active, :created, :updated)");
|
||||
$stmt->bindValue(':created', $now, SQLITE3_INTEGER);
|
||||
}
|
||||
|
||||
$stmt->bindValue(':name', $itemName, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':stock', $stock, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':offer', $temporaryOffer, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':expire', $offerExpire, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':price', $price, SQLITE3_FLOAT);
|
||||
$stmt->bindValue(':discount_price', $discountPrice, SQLITE3_FLOAT);
|
||||
$stmt->bindValue(':bond', $bondReward, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':active', $isActive, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':updated', $now, SQLITE3_INTEGER);
|
||||
|
||||
$stmt->execute();
|
||||
|
||||
$savedId = $id > 0 ? $id : $db->lastInsertRowID();
|
||||
success(['id' => $savedId, 'discount_price' => $discountPrice], $id > 0 ? '配置已更新' : '配置已添加');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除商店配置
|
||||
*/
|
||||
function handleShopConfigDelete($db) {
|
||||
$id = intval(getParam('id', 0));
|
||||
if ($id <= 0) {
|
||||
error('无效的配置ID');
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("DELETE FROM shop_configs WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
if ($db->changes() > 0) {
|
||||
success([], '配置已删除');
|
||||
} else {
|
||||
error('配置不存在');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换商店配置上下架状态
|
||||
*/
|
||||
function handleShopConfigToggle($db) {
|
||||
$id = intval(getParam('id', 0));
|
||||
if ($id <= 0) {
|
||||
error('无效的配置ID');
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("UPDATE shop_configs SET is_active = CASE WHEN is_active = 1 THEN 0 ELSE 1 END, updated_at = :updated WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':updated', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
if ($db->changes() > 0) {
|
||||
// 获取新状态
|
||||
$check = $db->prepare("SELECT is_active FROM shop_configs WHERE id = :id");
|
||||
$check->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$row = $check->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
$newStatus = $row ? $row['is_active'] : 0;
|
||||
success(['is_active' => $newStatus], $newStatus ? '已上架' : '已下架');
|
||||
} else {
|
||||
error('配置不存在');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析有效期日期(支持yyyy-MM-dd和中文格式如"2026年7月16日")
|
||||
*/
|
||||
function parseExpireDate($expireStr) {
|
||||
if (empty($expireStr)) {
|
||||
return ['valid' => true, 'timestamp' => 0, 'display' => '长期有效'];
|
||||
}
|
||||
|
||||
$expireStr = trim($expireStr);
|
||||
|
||||
// 尝试解析yyyy-MM-dd格式
|
||||
if (preg_match('/^\d{4}-\d{1,2}-\d{1,2}$/', $expireStr)) {
|
||||
$time = strtotime($expireStr . ' 23:59:59');
|
||||
if ($time !== false) {
|
||||
return [
|
||||
'valid' => true,
|
||||
'timestamp' => $time,
|
||||
'display' => date('Y-m-d', $time) . ' 到期'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试解析中文格式(如"2026年7月16日")
|
||||
if (preg_match('/(\d{4})年(\d{1,2})月(\d{1,2})日/', $expireStr, $matches)) {
|
||||
$year = $matches[1];
|
||||
$month = $matches[2];
|
||||
$day = $matches[3];
|
||||
$time = mktime(23, 59, 59, $month, $day, $year);
|
||||
if ($time !== false) {
|
||||
return [
|
||||
'valid' => true,
|
||||
'timestamp' => $time,
|
||||
'display' => "{$year}-{$month}-{$day} 到期"
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试使用strtotime解析其他格式
|
||||
$time = strtotime($expireStr);
|
||||
if ($time !== false && $time > time()) {
|
||||
return [
|
||||
'valid' => true,
|
||||
'timestamp' => $time,
|
||||
'display' => date('Y-m-d', $time) . ' 到期'
|
||||
];
|
||||
}
|
||||
|
||||
return ['valid' => false, 'timestamp' => 0, 'display' => '无效日期'];
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
<?php
|
||||
/**
|
||||
* 注册API
|
||||
* POST ?action=check - 检查用户名是否已注册
|
||||
* POST ?action=register - 注册新账号(需token)
|
||||
* GET ?action=query - 查询账号信息(需token)
|
||||
*/
|
||||
// 防止任何输出污染JSON响应
|
||||
ob_start();
|
||||
require_once __DIR__ . '/../core.php';
|
||||
ob_end_clean();
|
||||
|
||||
$action = getParam('action', 'check');
|
||||
$token = getParam('token');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'check':
|
||||
registerCheck();
|
||||
break;
|
||||
case 'register':
|
||||
registerAccount($token);
|
||||
break;
|
||||
case 'web_register':
|
||||
webRegister();
|
||||
break;
|
||||
case 'query':
|
||||
registerQuery($token);
|
||||
break;
|
||||
default:
|
||||
error('未知操作: ' . $action);
|
||||
}
|
||||
|
||||
// ★ WAL checkpoint:释放WAL锁,减少database is locked概率
|
||||
try { walCheckpoint(); } catch (\Throwable $ignored) {}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
while (ob_get_level() > 0) ob_end_clean();
|
||||
error('服务器内部错误: ' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
// ===== 检查用户名 =====
|
||||
function registerCheck() {
|
||||
$player = getParam('player');
|
||||
if (!$player) error('缺少player参数');
|
||||
|
||||
$db = getDB();
|
||||
// 只查询users表实际存在的列
|
||||
$stmt = $db->prepare("SELECT player_name, register_time, email FROM users WHERE player_name = :name");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
success([
|
||||
'exists' => !!$row,
|
||||
'player' => $player,
|
||||
'info' => $row ?: null
|
||||
]);
|
||||
}
|
||||
|
||||
// ===== 注册账号 =====
|
||||
function registerAccount($token) {
|
||||
$player = getParam('player');
|
||||
$passwordHash = getParam('password_hash');
|
||||
$salt = getParam('salt');
|
||||
$email = getParam('email', '');
|
||||
$ip = getParam('ip', '');
|
||||
|
||||
if (!$player) error('缺少player');
|
||||
if (!$passwordHash) error('缺少password_hash');
|
||||
if (!$salt) error('缺少salt');
|
||||
|
||||
// ★ 第56批:邮箱后缀校验(与Java端一致)
|
||||
if ($email !== '' && !checkEmailSuffix($email)) {
|
||||
$mode = '默认';
|
||||
$cfgDb = getDB();
|
||||
$cfgRow = $cfgDb->query("SELECT mode FROM email_suffix_config WHERE id = 1")->fetchArray(SQLITE3_ASSOC);
|
||||
if ($cfgRow) $mode = (string)$cfgRow['mode'];
|
||||
if ($mode === '白名单') error('该邮箱后缀不在允许列表中');
|
||||
else if ($mode === '黑名单') error('该邮箱后缀被禁止使用');
|
||||
else error('该邮箱后缀不可用');
|
||||
}
|
||||
|
||||
// 验证token(注册需要token)
|
||||
$isPreview = true;
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo) {
|
||||
$isPreview = false;
|
||||
validateAndUseToken($token);
|
||||
}
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
|
||||
// 检查是否已注册
|
||||
$stmt = $db->prepare("SELECT 1 FROM users WHERE player_name = :name");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
if ($result->fetchArray()) {
|
||||
error('账号已存在: ' . $player);
|
||||
}
|
||||
|
||||
if ($isPreview) {
|
||||
preview([
|
||||
'player' => $player,
|
||||
'email' => $email,
|
||||
'would_register' => true
|
||||
], '预览模式 - 注册不会生效');
|
||||
}
|
||||
|
||||
// 执行注册
|
||||
$now = time();
|
||||
$stmt = $db->prepare("INSERT INTO users (player_name, register_time, email) VALUES (:name, :time, :email)");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':email', $email, SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
|
||||
// 记录
|
||||
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, created_at) VALUES (:player, 'register', 0, 'Web端注册', :time)");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
success(['player' => $player], '注册成功');
|
||||
}
|
||||
|
||||
// ===== Web注册请求(由Web端提交,等待插件同步)=====
|
||||
function webRegister() {
|
||||
$webToken = getParam('web_token');
|
||||
$password = getParam('password');
|
||||
$email = getParam('email', '');
|
||||
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
|
||||
if (!$webToken) error('缺少web_token');
|
||||
if (!$password) error('缺少password');
|
||||
if (strlen($password) < 6) error('密码长度至少6位');
|
||||
|
||||
// ★ 第56批:邮箱后缀校验(与Java端一致)
|
||||
if ($email !== '' && !checkEmailSuffix($email)) {
|
||||
$mode = '默认';
|
||||
$cfgDb = getDB();
|
||||
$cfgRow = $cfgDb->query("SELECT mode FROM email_suffix_config WHERE id = 1")->fetchArray(SQLITE3_ASSOC);
|
||||
if ($cfgRow) $mode = (string)$cfgRow['mode'];
|
||||
if ($mode === '白名单') error('该邮箱后缀不在允许列表中');
|
||||
else if ($mode === '黑名单') error('该邮箱后缀被禁止使用');
|
||||
else error('该邮箱后缀不可用');
|
||||
}
|
||||
|
||||
// 验证web_token
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE web_token = :token");
|
||||
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$tokenRow = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$tokenRow) error('无效的Web登录Token');
|
||||
|
||||
$createdAt = (int)($tokenRow['created_at'] ?? 0);
|
||||
$expireSeconds = (int)($tokenRow['expire_seconds'] ?? 600);
|
||||
if (time() - $createdAt > $expireSeconds) error('Web登录Token已过期');
|
||||
|
||||
$playerName = $tokenRow['player_name'];
|
||||
|
||||
// 检查是否已注册
|
||||
if (isPlayerRegistered($playerName)) {
|
||||
error('玩家已注册: ' . $playerName);
|
||||
}
|
||||
|
||||
// 生成密码凭证(与插件相同的SHA-256算法)
|
||||
$saltBytes = random_bytes(16);
|
||||
$salt = base64_encode($saltBytes);
|
||||
$passwordHash = base64_encode(hash('sha256', $saltBytes . $password, true));
|
||||
|
||||
// ★ 创建web_register_requests表记录(等待插件同步)
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_register_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
salt TEXT NOT NULL,
|
||||
email TEXT DEFAULT '',
|
||||
ip_address TEXT DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
processed_at INTEGER DEFAULT 0
|
||||
)");
|
||||
|
||||
// 插入注册请求
|
||||
$stmt = $db->prepare("INSERT INTO web_register_requests (player_name, password_hash, salt, email, ip_address, created_at) VALUES (:player, :hash, :salt, :email, :ip, :time)");
|
||||
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':hash', $passwordHash, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':salt', $salt, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':email', $email, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':ip', $ipAddress, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$requestId = $db->lastInsertRowID();
|
||||
|
||||
success([
|
||||
'player' => $playerName,
|
||||
'request_id' => $requestId,
|
||||
'email' => $email
|
||||
], '注册请求已提交,等待游戏同步');
|
||||
}
|
||||
|
||||
// ===== 查询账号信息 =====
|
||||
function registerQuery($token) {
|
||||
$player = getParam('player');
|
||||
if (!$player) error('缺少player');
|
||||
|
||||
$db = getDB();
|
||||
// 只查询users表实际存在的列
|
||||
$stmt = $db->prepare("SELECT player_name, register_time, last_login_time, email, points, gift_stage, total_online_time FROM users WHERE player_name = :name");
|
||||
$stmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
error('账号不存在: ' . $player);
|
||||
}
|
||||
|
||||
// 处理Java插件推送的毫秒级时间戳,转换为秒级
|
||||
if ($row['register_time'] && $row['register_time'] > 1000000000000) {
|
||||
$row['register_time'] = (int)($row['register_time'] / 1000);
|
||||
}
|
||||
if ($row['last_login_time'] && $row['last_login_time'] > 1000000000000) {
|
||||
$row['last_login_time'] = (int)($row['last_login_time'] / 1000);
|
||||
}
|
||||
|
||||
// ★ 获取债券余额
|
||||
$bonds = 0;
|
||||
try {
|
||||
$bondStmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :name");
|
||||
$bondStmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$bondResult = $bondStmt->execute();
|
||||
$bondRow = $bondResult->fetchArray(SQLITE3_ASSOC);
|
||||
if ($bondRow) $bonds = (int)($bondRow['amount'] ?? 0);
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// ★ 获取累计在线天数(不重复日期计数)
|
||||
$totalOnlineDays = 0;
|
||||
try {
|
||||
$odStmt = $db->prepare("SELECT COUNT(DISTINCT login_date) as cnt FROM player_daily_logins WHERE player_name = :name");
|
||||
$odStmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$odResult = $odStmt->execute();
|
||||
$odRow = $odResult->fetchArray(SQLITE3_ASSOC);
|
||||
if ($odRow) $totalOnlineDays = (int)($odRow['cnt'] ?? 0);
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// ★ 获取累计签到天数
|
||||
$totalCheckinDays = 0;
|
||||
try {
|
||||
$ciStmt = $db->prepare("SELECT COUNT(DISTINCT checkin_date) as cnt FROM player_checkins WHERE player_name = :name");
|
||||
$ciStmt->bindValue(':name', $player, SQLITE3_TEXT);
|
||||
$ciResult = $ciStmt->execute();
|
||||
$ciRow = $ciResult->fetchArray(SQLITE3_ASSOC);
|
||||
if ($ciRow) $totalCheckinDays = (int)($ciRow['cnt'] ?? 0);
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// 合并额外数据
|
||||
$row['bonds'] = $bonds;
|
||||
$row['total_online_days'] = $totalOnlineDays;
|
||||
$row['total_checkin_days'] = $totalCheckinDays;
|
||||
|
||||
// 邮箱脱敏
|
||||
if (!empty($row['email'])) {
|
||||
$parts = explode('@', $row['email']);
|
||||
if (count($parts) === 2) {
|
||||
$row['masked_email'] = str_repeat('*', min(strlen($parts[0]), 3)) . '@' . $parts[1];
|
||||
} else {
|
||||
$row['masked_email'] = '***';
|
||||
}
|
||||
} else {
|
||||
$row['masked_email'] = '';
|
||||
}
|
||||
|
||||
// ★ 严格校验:必须有有效token且token对应的玩家必须与查询的玩家匹配(除非是管理员token)
|
||||
if (!$token) {
|
||||
// 无token:隐藏敏感信息
|
||||
$previewRow = [
|
||||
'player_name' => $row['player_name'],
|
||||
'register_time' => $row['register_time'],
|
||||
'last_login_time' => $row['last_login_time'],
|
||||
'email' => '',
|
||||
'masked_email' => '',
|
||||
'points' => 0,
|
||||
'gift_stage' => 0,
|
||||
'total_online_time' => 0,
|
||||
'bonds' => 0,
|
||||
'total_online_days' => 0,
|
||||
'total_checkin_days' => 0
|
||||
];
|
||||
preview($previewRow, '预览模式 - 部分信息已隐藏');
|
||||
return;
|
||||
}
|
||||
|
||||
$tokenInfo = validateTokenSilent($token);
|
||||
if (!$tokenInfo) {
|
||||
error('无效token,请先登录', 401);
|
||||
}
|
||||
|
||||
$tokenPurpose = $tokenInfo['purpose'] ?? '';
|
||||
|
||||
if ($tokenPurpose === 'admin' || $tokenPurpose === 'all') {
|
||||
// 管理员token:返回完整信息
|
||||
success($row);
|
||||
} elseif ($tokenInfo['player'] === $player) {
|
||||
// 普通token:只能查自己的完整信息
|
||||
success($row);
|
||||
} else {
|
||||
// 不是自己的账号,返回脱敏信息
|
||||
$maskedRow = [
|
||||
'player_name' => $row['player_name'],
|
||||
'register_time' => $row['register_time'],
|
||||
'last_login_time' => $row['last_login_time'],
|
||||
'email' => '',
|
||||
'masked_email' => '***',
|
||||
'points' => 0,
|
||||
'gift_stage' => 0,
|
||||
'total_online_time' => 0,
|
||||
'bonds' => 0,
|
||||
'total_online_days' => 0,
|
||||
'total_checkin_days' => 0
|
||||
];
|
||||
preview($maskedRow, '预览模式 - 您只能查看自己的账号信息');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
/**
|
||||
* 异地登录检测回调(Java 插件在玩家加入服务器时异步调用)
|
||||
*
|
||||
* 调用方式:GET api/security_alert.php
|
||||
* ?action=login_location_alert&secret=...&name=<玩家>&ip=<登录IP>
|
||||
*
|
||||
* 职责:
|
||||
* 1. 用 ip9.com.cn 查询本次登录 IP 归属
|
||||
* 2. 与上次登录归属比较,不一致且非首次 → 向绑定邮箱发送提醒邮件
|
||||
* 3. 邮件内含「冻结账号」与「修改密码」两个链接
|
||||
* 4. 更新 users.last_login_location / last_login_ip
|
||||
*/
|
||||
require_once __DIR__ . '/../core.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
if ($action !== 'login_location_alert') {
|
||||
echo json_encode(['success' => false, 'message' => '未知操作']);
|
||||
exit;
|
||||
}
|
||||
$secret = $_GET['secret'] ?? '';
|
||||
if (!$secret || $secret !== SECRET_KEY) {
|
||||
echo json_encode(['success' => false, 'message' => '密钥错误']);
|
||||
exit;
|
||||
}
|
||||
$name = $_GET['name'] ?? '';
|
||||
$ip = $_GET['ip'] ?? '';
|
||||
if (!$name || !$ip) {
|
||||
echo json_encode(['success' => false, 'message' => '参数缺失']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
|
||||
// 查询账号信息
|
||||
$stmt = $db->prepare(
|
||||
"SELECT email, last_login_location, last_login_ip, frozen
|
||||
FROM users WHERE player_name = :name");
|
||||
$stmt->bindValue(':name', $name, SQLITE3_TEXT);
|
||||
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
// 查询本次 IP 归属
|
||||
$location = queryLoginIpLocation($ip);
|
||||
|
||||
if ($location === null) {
|
||||
// 归属查询失败:仅更新 IP,不阻断、不提醒
|
||||
$upd = $db->prepare(
|
||||
"UPDATE users SET last_login_ip = :ip WHERE player_name = :name");
|
||||
$upd->bindValue(':ip', $ip, SQLITE3_TEXT);
|
||||
$upd->bindValue(':name', $name, SQLITE3_TEXT);
|
||||
$upd->execute();
|
||||
echo json_encode(['success' => true, 'message' => 'IP归属查询失败,仅更新IP']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$row) {
|
||||
// 理论上 Java 已 syncUserOnJoin,不会走到这里
|
||||
echo json_encode(['success' => true, 'message' => '无用户记录']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$lastLoc = $row['last_login_location'] ?? '';
|
||||
$lastIp = $row['last_login_ip'] ?? '';
|
||||
$email = $row['email'] ?? '';
|
||||
|
||||
// 更新归属与 IP
|
||||
$upd = $db->prepare(
|
||||
"UPDATE users SET last_login_ip = :ip,
|
||||
last_login_location = :loc WHERE player_name = :name");
|
||||
$upd->bindValue(':ip', $ip, SQLITE3_TEXT);
|
||||
$upd->bindValue(':loc', $location, SQLITE3_TEXT);
|
||||
$upd->bindValue(':name', $name, SQLITE3_TEXT);
|
||||
$upd->execute();
|
||||
|
||||
// 首次登录(无任何历史记录)不提醒
|
||||
if ($lastLoc === '' && $lastIp === '') {
|
||||
echo json_encode(['success' => true, 'message' => '首次登录,已记录']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 归属变化才发送提醒邮件
|
||||
if ($lastLoc !== $location && !empty($email)) {
|
||||
// 生成冻结 token 并持久化,供邮件中的冻结链接使用
|
||||
$freezeToken = bin2hex(random_bytes(16));
|
||||
$ut = $db->prepare("UPDATE users SET freeze_token = :t WHERE player_name = :name");
|
||||
$ut->bindValue(':t', $freezeToken, SQLITE3_TEXT);
|
||||
$ut->bindValue(':name', $name, SQLITE3_TEXT);
|
||||
$ut->execute();
|
||||
sendLocationAlertEmail($name, $email, $ip, $location, $lastLoc, $freezeToken);
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'ok']);
|
||||
@@ -0,0 +1,711 @@
|
||||
<?php
|
||||
/**
|
||||
* 商城API
|
||||
* GET ?action=list - 商品列表(无需token,公开接口)
|
||||
* POST ?action=buy - 购买商品(需token)
|
||||
* GET ?action=categories - 分类列表(无需token,公开接口)
|
||||
*/
|
||||
// 防止任何输出污染JSON响应
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
require_once __DIR__ . '/../core.php';
|
||||
// 再次确保清理前置输出
|
||||
while (ob_get_level() > 0) { ob_end_clean(); }
|
||||
|
||||
$action = getParam('action', 'list');
|
||||
$token = getParam('token');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
shopList($token);
|
||||
break;
|
||||
case 'buy':
|
||||
shopBuy($token);
|
||||
break;
|
||||
case 'buy_cart':
|
||||
shopBuyCart($token);
|
||||
break;
|
||||
case 'cart_config':
|
||||
cartConfig();
|
||||
break;
|
||||
case 'categories':
|
||||
shopCategories($token);
|
||||
break;
|
||||
default:
|
||||
error('未知操作: ' . $action);
|
||||
}
|
||||
|
||||
// ★ WAL checkpoint:释放WAL锁,减少database is locked概率
|
||||
try { walCheckpoint(); } catch (\Throwable $ignored) {}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// ★ 强制回滚可能残留的事务(防止database is locked连锁故障)
|
||||
try { $db = getDB(); $db->exec('ROLLBACK'); } catch (\Throwable $_) {}
|
||||
while (ob_get_level() > 0) ob_end_clean();
|
||||
error('服务器内部错误: ' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
// ===== 商品列表(公开,无需token) =====
|
||||
function shopList($token = null) {
|
||||
try {
|
||||
$db = getDB();
|
||||
$category = getParam('category');
|
||||
|
||||
if ($category) {
|
||||
$stmt = $db->prepare("SELECT * FROM shop_items WHERE category = :cat ORDER BY id");
|
||||
$stmt->bindValue(':cat', $category, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
} else {
|
||||
$result = $db->query("SELECT * FROM shop_items ORDER BY category, id");
|
||||
}
|
||||
|
||||
$items = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$items[] = $row;
|
||||
}
|
||||
|
||||
// 直接返回数据,不验证token(公开接口)
|
||||
$isPreview = !$token;
|
||||
if ($token && validateTokenSilent($token)) {
|
||||
$isPreview = false;
|
||||
}
|
||||
|
||||
if ($isPreview) {
|
||||
preview($items, '预览模式 - 无有效token');
|
||||
} else {
|
||||
success($items);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error('商城加载失败: ' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
// validateTokenSilent() 已在 core.php 中定义,不再重复声明
|
||||
|
||||
// ===== 分类列表 =====
|
||||
function shopCategories() {
|
||||
$db = getDB();
|
||||
$result = $db->query("SELECT DISTINCT category, COUNT(*) as item_count FROM shop_items GROUP BY category");
|
||||
$cats = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$cats[] = $row;
|
||||
}
|
||||
success($cats);
|
||||
}
|
||||
|
||||
// ===== 购买商品 =====
|
||||
function shopBuy($token) {
|
||||
$itemId = getParam('item_id');
|
||||
$amount = (int)getParam('amount', 1);
|
||||
$player = getParam('player');
|
||||
$password = getParam('password');
|
||||
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
|
||||
if (!$itemId) error('缺少item_id');
|
||||
if (!$player) error('缺少player');
|
||||
if ($amount < 1) error('数量无效');
|
||||
|
||||
// ★ 双保险验证:购买操作需要密码确认
|
||||
$isPreview = true;
|
||||
$isOnline = false;
|
||||
$isRegistered = false;
|
||||
|
||||
if ($token) {
|
||||
// 先检查是否为普通管理token
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
|
||||
$isPreview = false;
|
||||
if (validateToken($token)) {
|
||||
validateAndUseToken($token);
|
||||
}
|
||||
} else {
|
||||
// weblogin token → 走双保险验证
|
||||
$accessResult = validateWebAccess($token, 'buy', $password, $ipAddress);
|
||||
if ($accessResult['ok']) {
|
||||
$isPreview = false;
|
||||
$player = $accessResult['player'];
|
||||
$isOnline = $accessResult['online'] ?? false;
|
||||
$isRegistered = $accessResult['registered'] ?? false;
|
||||
} elseif ($accessResult['mode'] === 'need_password') {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'need_password' => true,
|
||||
'player' => $accessResult['player'],
|
||||
'message' => $accessResult['message']
|
||||
], 401);
|
||||
} elseif ($accessResult['mode'] === 'need_game_login') {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'need_game_login' => true,
|
||||
'player' => $accessResult['player'],
|
||||
'message' => $accessResult['message']
|
||||
], 401);
|
||||
} elseif ($accessResult['mode'] === 'need_register') {
|
||||
jsonResponse([
|
||||
'success' => false,
|
||||
'need_register' => true,
|
||||
'player' => $accessResult['player'],
|
||||
'message' => $accessResult['message']
|
||||
], 401);
|
||||
} else {
|
||||
error($accessResult['message'], 401);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查询商品
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM shop_items WHERE id = :id");
|
||||
$stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$item = $result->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$item) error('商品不存在: ' . $itemId);
|
||||
|
||||
// 检查库存
|
||||
if ($item['stock'] == 0) error('商品已售罄');
|
||||
if ($item['stock'] > 0 && $item['stock'] < $amount) error('库存不足');
|
||||
|
||||
$totalPrice = $item['buy_price'] * $amount;
|
||||
|
||||
// 检查玩家余额
|
||||
if (!$isPreview && $totalPrice > 0) {
|
||||
$balanceStmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :player");
|
||||
$balanceStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$balanceResult = $balanceStmt->execute();
|
||||
$balanceRow = $balanceResult->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$balanceRow) {
|
||||
error('玩家余额查询失败');
|
||||
}
|
||||
|
||||
$balance = (int)$balanceRow['amount'];
|
||||
if ($balance < $totalPrice) {
|
||||
error('余额不足,当前余额: ' . $balance . ' 债券,需要: ' . $totalPrice . ' 债券');
|
||||
}
|
||||
}
|
||||
|
||||
if ($isPreview) {
|
||||
// 预览模式:返回计算结果但不执行
|
||||
preview([
|
||||
'item_id' => $itemId,
|
||||
'item_name' => $item['display_name'],
|
||||
'amount' => $amount,
|
||||
'unit_price' => $item['buy_price'],
|
||||
'total_price' => $totalPrice,
|
||||
'stock_before' => $item['stock'],
|
||||
'stock_after' => $item['stock'] > 0 ? $item['stock'] - $amount : -1,
|
||||
'player' => $player
|
||||
], '预览模式 - 购买不会生效');
|
||||
}
|
||||
|
||||
// ★ 实际购买 — 包装在事务中,防止database is locked
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
// 更新库存(同时写admin_stock确保Java同步)
|
||||
if ($item['stock'] > 0) {
|
||||
$newStock = $item['stock'] - $amount;
|
||||
$stmt = $db->prepare("UPDATE shop_items SET stock = :ns, admin_stock = :ns, last_modified = :t, hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
|
||||
$stmt->bindValue(':ns', $newStock, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
} else {
|
||||
// 无限库存,只更新销量
|
||||
$stmt = $db->prepare("UPDATE shop_items SET hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
|
||||
$stmt->bindValue(':amount', $amount, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $itemId, SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
// 记录交易
|
||||
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'shop_buy', :amount, :reason, :detail, 'pending', :time)");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':amount', $totalPrice, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':reason', "购买: {$item['display_name']} x{$amount}", SQLITE3_TEXT);
|
||||
$stmt->bindValue(':detail', json_encode([
|
||||
'item_id' => $itemId,
|
||||
'amount' => $amount,
|
||||
'unit_price' => $item['buy_price'],
|
||||
'tx_id' => bin2hex(random_bytes(8))
|
||||
], JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
// ★ 写入sync_requests(必须在success/exit之前!)
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
|
||||
$stmt2 = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
|
||||
$stmt2->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt2->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt2->execute();
|
||||
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
try { $db->exec('ROLLBACK'); } catch (Exception $e2) {}
|
||||
error('购买失败: ' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
success([
|
||||
'item_id' => $itemId,
|
||||
'item_name' => $item['display_name'],
|
||||
'amount' => $amount,
|
||||
'total_price' => $totalPrice,
|
||||
'player' => $player
|
||||
], '购买成功');
|
||||
}
|
||||
|
||||
// ===== 购物车配置读取(批量优化:单次查询读取所有需要的配置) =====
|
||||
function getShopConfig($key, $default = null) {
|
||||
try {
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT cfg_value FROM shop_config WHERE cfg_key = :k");
|
||||
$stmt->bindValue(':k', $key, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
return $row ? $row['cfg_value'] : $default;
|
||||
} catch (Exception $e) {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量读取多个shop_config键(一次查询替代N次getShopConfig调用)
|
||||
* @return array key => value (不存在的key不返回)
|
||||
*/
|
||||
function getShopConfigs(array $keys) {
|
||||
if (empty($keys)) return [];
|
||||
try {
|
||||
$db = getDB();
|
||||
$placeholders = implode(',', array_fill(0, count($keys), '?'));
|
||||
$stmt = $db->prepare("SELECT cfg_key, cfg_value FROM shop_config WHERE cfg_key IN ($placeholders)");
|
||||
foreach ($keys as $i => $k) {
|
||||
$stmt->bindValue($i + 1, $k, SQLITE3_TEXT);
|
||||
}
|
||||
$result = $stmt->execute();
|
||||
$map = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$map[$row['cfg_key']] = $row['cfg_value'];
|
||||
}
|
||||
return $map;
|
||||
} catch (Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 返回购物车折扣/加价配置(前端展示用,★ 批量读取)
|
||||
function cartConfig() {
|
||||
$cfg = getShopConfigs(['cart_backpack_rate', 'cart_shulker_rate', 'packmoney', 'green_discount']);
|
||||
success([
|
||||
'backpack_rate' => (float)($cfg['cart_backpack_rate'] ?? '0.98'),
|
||||
'shulker_rate' => (float)($cfg['cart_shulker_rate'] ?? '1.00'),
|
||||
'packmoney' => (float)($cfg['packmoney'] ?? '5'),
|
||||
'green_discount' => (float)($cfg['green_discount'] ?? '10')
|
||||
]);
|
||||
}
|
||||
|
||||
// ===== 购物车结算(多商品一次性购买) =====
|
||||
function shopBuyCart($token) {
|
||||
$__t0 = microtime(true);
|
||||
debugLog('[buy_cart] 进入请求');
|
||||
$rawItems = getParam('items');
|
||||
$settlement = getParam('settlement', 'backpack'); // backpack=塞背包, shulker=潜影盒打包
|
||||
$shulkerColor = getParam('shulker_color', 'default'); // 潜影盒颜色(default原色免费,其它+2元)
|
||||
$payMode = getParam('pay_mode', 'bond'); // bond=债券扣款, cash=现金仅记账
|
||||
$player = getParam('player');
|
||||
$password = getParam('password');
|
||||
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
|
||||
if (!$rawItems) error('购物车为空');
|
||||
$items = json_decode($rawItems, true);
|
||||
if (!is_array($items) || count($items) === 0) error('购物车格式错误');
|
||||
|
||||
// 校验每一项
|
||||
$parsed = [];
|
||||
foreach ($items as $it) {
|
||||
if (!isset($it['item_id']) || !isset($it['amount'])) error('商品项缺少字段');
|
||||
$pid = (string)$it['item_id'];
|
||||
$amt = (int)$it['amount'];
|
||||
if ($amt < 1) error('数量无效: ' . $pid);
|
||||
$parsed[] = ['item_id' => $pid, 'amount' => $amt];
|
||||
}
|
||||
if (!$player) error('缺少player');
|
||||
|
||||
// ★ 双保险验证(与单件购买一致)
|
||||
$isPreview = true;
|
||||
$isAdminToken = false;
|
||||
$isOnline = false;
|
||||
$isRegistered = false;
|
||||
$isCashier = false;
|
||||
$isAdminSession = false;
|
||||
$operatorDiscountLimit = 0;
|
||||
$cashierRow = null;
|
||||
$orderNo = '';
|
||||
|
||||
if ($token) {
|
||||
$tokenInfo = validateToken($token);
|
||||
if ($tokenInfo && ($tokenInfo['purpose'] === 'admin' || $tokenInfo['purpose'] === 'all')) {
|
||||
$isPreview = false;
|
||||
$isAdminToken = true; // ★ 记录代购令牌身份,避免下方大额复检重复校验已消费的token导致误判
|
||||
if (validateToken($token)) validateAndUseToken($token);
|
||||
} else {
|
||||
$accessResult = validateWebAccess($token, 'buy', $password, $ipAddress);
|
||||
if ($accessResult['ok']) {
|
||||
$isPreview = false;
|
||||
$player = $accessResult['player'];
|
||||
$isOnline = $accessResult['online'] ?? false;
|
||||
$isRegistered = $accessResult['registered'] ?? false;
|
||||
} elseif (($accessResult['mode'] ?? '') === 'need_password') {
|
||||
jsonResponse(['success' => false, 'need_password' => true, 'player' => $accessResult['player'], 'message' => $accessResult['message']], 401);
|
||||
} elseif (($accessResult['mode'] ?? '') === 'need_game_login') {
|
||||
jsonResponse(['success' => false, 'need_game_login' => true, 'player' => $accessResult['player'], 'message' => $accessResult['message']], 401);
|
||||
} elseif (($accessResult['mode'] ?? '') === 'need_register') {
|
||||
jsonResponse(['success' => false, 'need_register' => true, 'player' => $accessResult['player'], 'message' => $accessResult['message']], 401);
|
||||
} else {
|
||||
error($accessResult['message'], 401);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 收银员/管理员会话代购通路(独立收银台,无需token)
|
||||
if (isCashierLoggedIn()) {
|
||||
$isPreview = false;
|
||||
$isCashier = true;
|
||||
$cashierRow = getCurrentCashier();
|
||||
$operatorDiscountLimit = (int)($cashierRow['discount_limit_percent'] ?? 0);
|
||||
} elseif (isAdminLoggedIn()) {
|
||||
$isPreview = false;
|
||||
$isAdminSession = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 结算模式对应的折扣/加价系数(★ 批量读取所有配置键,1次查询替代4次)
|
||||
$cfg = getShopConfigs(['cart_shulker_rate', 'packmoney', 'cart_backpack_rate', 'green_discount']);
|
||||
if ($settlement === 'shulker') {
|
||||
$rate = (float)($cfg['cart_shulker_rate'] ?? '1.00');
|
||||
$modeName = '潜影盒打包';
|
||||
// 潜影盒颜色额外收费(原色/紫色免费,其它加收打包费,打包费来自配置 packmoney)
|
||||
$packMoney = (float)($cfg['packmoney'] ?? '5');
|
||||
$colorFee = ($shulkerColor !== 'default' && $shulkerColor !== 'purple') ? $packMoney : 0;
|
||||
// 颜色名称映射
|
||||
$colorNames = ['default'=>'原色','purple'=>'原色','white'=>'白色','black'=>'黑色','red'=>'红色','blue'=>'蓝色','green'=>'绿色','yellow'=>'黄色','orange'=>'橙色'];
|
||||
$colorName = $colorNames[$shulkerColor] ?? $shulkerColor;
|
||||
$ecoPct = 0;
|
||||
} else {
|
||||
$settlement = 'backpack';
|
||||
$rawBackpackRate = (float)($cfg['cart_backpack_rate'] ?? '0.98');
|
||||
// ★ 修复(2026-07-08):背包(环保单)模式必须尊重游戏内 green_discount 设置。
|
||||
// green_discount 语义:10=不打折,9=9折(×0.9),8.5=8.5折(×0.85)。
|
||||
// 游戏内设置了打折(green_discount < 10)时,以游戏折扣为准(如 setgreen 9 → ×0.9),
|
||||
// 覆盖默认的 cart_backpack_rate(0.98=98折);未设置(=10)时仍走 cart_backpack_rate 保持原有环保优惠。
|
||||
$greenDiscount = (float)($cfg['green_discount'] ?? 10);
|
||||
$rate = ($greenDiscount < 10) ? ($greenDiscount / 10) : $rawBackpackRate;
|
||||
$modeName = '塞背包(环保单)';
|
||||
$colorFee = 0;
|
||||
$colorName = '';
|
||||
$ecoPct = 0;
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$lines = [];
|
||||
$subtotal = 0;
|
||||
foreach ($parsed as &$p) {
|
||||
$stmt = $db->prepare("SELECT * FROM shop_items WHERE id = :id");
|
||||
$stmt->bindValue(':id', $p['item_id'], SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$item = $result->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$item) error('商品不存在: ' . $p['item_id']);
|
||||
if ($item['stock'] == 0) error('商品已售罄: ' . $item['display_name']);
|
||||
if ($item['stock'] > 0 && $item['stock'] < $p['amount']) error('库存不足: ' . $item['display_name'] . '(剩余' . $item['stock'] . ')');
|
||||
|
||||
// ★ 发货前验证:检查商品material是否为Java端可识别的有效值
|
||||
// 防止扣款后发现Java无法匹配该材料(如附魔书ID被误写为material字段)导致钱扣了货没给
|
||||
$mat = strtoupper(trim($item['material']));
|
||||
$validMaterial = (
|
||||
// 标准Bukkit Material:大写字母+数字+下划线,如 DIAMOND_SWORD, ENCHANTED_BOOK
|
||||
preg_match('/^[A-Z][A-Z0-9_]+$/', $mat) &&
|
||||
// 排除明显不是合法Material的值(如附魔书ID MENDING_I 等——这些应写在id而非material列)
|
||||
// ENCHANTED_BOOK 是唯一合法的附魔书Material
|
||||
($mat === 'ENCHANTED_BOOK' || !preg_match('/_(I{1,3}|II|III|IV|V|[1-5])$/', $mat))
|
||||
);
|
||||
// 允许 id 字段是附魔书格式(如 MENDING_I),此时 material 必须是 ENCHANTED_BOOK
|
||||
if (!$validMaterial) {
|
||||
$pid = strtoupper(trim($p['item_id']));
|
||||
$isEnchantedId = preg_match('/^[A-Z][A-Z0-9]*_[IVX12]+$/', $pid);
|
||||
if (!($isEnchantedId && $mat === 'ENCHANTED_BOOK') && $mat !== 'ENCHANTED_BOOK') {
|
||||
error('商品无法发放(material无效): ' . $item['display_name']
|
||||
. ' [material=' . $item['material'] . ', id=' . $p['item_id'] . ']'
|
||||
. ' — 请管理员检查该商品的material字段是否正确');
|
||||
}
|
||||
}
|
||||
$lineTotal = (int)$item['buy_price'] * $p['amount'];
|
||||
$subtotal += $lineTotal;
|
||||
$p['item'] = $item;
|
||||
$lines[] = [
|
||||
'item_id' => $p['item_id'],
|
||||
'name' => $item['display_name'],
|
||||
'unit_price' => (int)$item['buy_price'],
|
||||
'amount' => $p['amount'],
|
||||
'line_total' => $lineTotal
|
||||
];
|
||||
}
|
||||
unset($p);
|
||||
|
||||
// 先按费率计算,再叠加环保单折扣(不打包/塞背包,折扣率为折数),最后加颜色打包费
|
||||
// green_discount 语义:10=不打折,9.9=9.9折(×0.99),9.8=9.8折(×0.98)
|
||||
$baseAfterRate = (int)round($subtotal * $rate);
|
||||
if ($ecoPct > 0) $baseAfterRate = (int)round($baseAfterRate * $ecoPct / 10);
|
||||
$totalPrice = $baseAfterRate + $colorFee;
|
||||
$saved = $subtotal - $totalPrice; // 折扣省下的(潜影盒加价+颜色费时为负)
|
||||
|
||||
// ===== 收银员/管理员手动折扣(服务端强制上限,前端声称不可信)=====
|
||||
$discountPercent = 0;
|
||||
$discountAmount = 0;
|
||||
$finalPrice = $totalPrice;
|
||||
$canDiscount = ($isCashier || $isAdminToken || $isAdminSession);
|
||||
if ($canDiscount) {
|
||||
$reqDiscount = (float)getParam('discount_percent', 0);
|
||||
if ($reqDiscount < 0) $reqDiscount = 0;
|
||||
if ($reqDiscount > 100) $reqDiscount = 100;
|
||||
// 收银员受自身折扣上限限制;管理员无限制(最大100)
|
||||
$limit = $isCashier ? $operatorDiscountLimit : 100;
|
||||
if ($reqDiscount > $limit) {
|
||||
error('折扣超过权限上限(收银员最大可打' . $limit . '% off)', 403);
|
||||
}
|
||||
$discountPercent = $reqDiscount;
|
||||
if ($discountPercent > 0) {
|
||||
$discountAmount = (int)round($totalPrice * $discountPercent / 100);
|
||||
$finalPrice = $totalPrice - $discountAmount;
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 安全检查:服务端计算的实际扣款额≥1000时,必须已通过密码验证(防止前端绕过)
|
||||
// 管理员代购令牌(admin/all)已在上方标记$isAdminToken并消费,此处不再重新校验token,
|
||||
// 而是校验代购操作者(管理员)登录密码;普通玩家通路走validateWebAccess已在上方完成验证;
|
||||
// 收银员通路已通过收银员会话登录,视为已授权,无需额外密码
|
||||
if ($finalPrice >= 1000 && !$isPreview) {
|
||||
if ($isCashier) {
|
||||
// 收银员已登录即授权,无需额外密码
|
||||
} elseif ($isAdminToken || $isAdminSession) {
|
||||
// 代购通路:校验管理员登录密码(代购操作者的密码),而非目标玩家游戏密码
|
||||
// ★ 首次部署:ADMIN_PASS 还是占位符 = 没配置密码,此时任何"管理员密码"
|
||||
// 都无从校验 → 一律按未通过处理(引导页会要求先设置密码)。
|
||||
$passReady = function_exists('secAdminPassConfigured')
|
||||
? secAdminPassConfigured()
|
||||
: (defined('ADMIN_PASS') && trim((string)ADMIN_PASS) !== ''
|
||||
&& trim((string)ADMIN_PASS) !== 'REPLACE_ME_ADMIN_PASSWORD');
|
||||
if (!$password || trim($password) === '' || !$passReady || $password !== ADMIN_PASS) {
|
||||
jsonResponse(['success' => false, 'need_password' => true,
|
||||
'player' => $player, 'message' => '金额≥1000债券需确认管理员密码'], 401);
|
||||
}
|
||||
} elseif (!isset($accessResult['ok'])) {
|
||||
// 非管理员且未通过密码验证 → 拒绝
|
||||
error('大额交易需验证游戏登录密码', 401);
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 现金收款权限校验:收银员需管理员授予 can_cash 权限
|
||||
if ($payMode === 'cash') {
|
||||
if ($isCashier && empty($cashierRow['can_cash'])) {
|
||||
error('该收银员无现金收款权限(需管理员在收银员管理中开启)', 403);
|
||||
}
|
||||
// 现金模式:仅记账,不扣玩家债券(跳过余额校验与扣款)
|
||||
}
|
||||
|
||||
// 检查余额(现金模式跳过:仅记账)
|
||||
if (!$isPreview && $finalPrice > 0 && $payMode !== 'cash') {
|
||||
$balanceStmt = $db->prepare("SELECT amount FROM bond_cache WHERE player_name = :player");
|
||||
$balanceStmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$balanceResult = $balanceStmt->execute();
|
||||
$balanceRow = $balanceResult->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$balanceRow) error('玩家余额查询失败');
|
||||
$balance = (int)$balanceRow['amount'];
|
||||
if ($balance < $finalPrice) {
|
||||
error('余额不足,当前余额: ' . $balance . ' 债券,需要: ' . $finalPrice . ' 债券');
|
||||
}
|
||||
}
|
||||
|
||||
if ($isPreview) {
|
||||
preview([
|
||||
'settlement' => $settlement,
|
||||
'mode_name' => $modeName,
|
||||
'rate' => $rate,
|
||||
'items' => $lines,
|
||||
'subtotal' => $subtotal,
|
||||
'total_price' => $totalPrice,
|
||||
'saved' => $saved,
|
||||
'player' => $player
|
||||
], '预览模式 - 结算不会生效');
|
||||
}
|
||||
|
||||
// ★ 实际购买 — 包装在事务中
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
foreach ($parsed as $p) {
|
||||
$item = $p['item'];
|
||||
if ($item['stock'] > 0) {
|
||||
$newStock = $item['stock'] - $p['amount'];
|
||||
$stmt = $db->prepare("UPDATE shop_items SET stock = :ns, admin_stock = :ns, last_modified = :t, hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
|
||||
$stmt->bindValue(':ns', $newStock, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':t', time(), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':amount', $p['amount'], SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $p['item_id'], SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
} else {
|
||||
$stmt = $db->prepare("UPDATE shop_items SET hourly_sales = hourly_sales + :amount, total_sales = total_sales + :amount WHERE id = :id");
|
||||
$stmt->bindValue(':amount', $p['amount'], SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $p['item_id'], SQLITE3_TEXT);
|
||||
$stmt->execute();
|
||||
}
|
||||
}
|
||||
|
||||
// 记录一条合并交易
|
||||
$reasonItems = array_map(function ($l) { return $l['name'] . ' x' . $l['amount']; }, $lines);
|
||||
$stmt = $db->prepare("INSERT INTO web_transactions (player_name, type, amount, reason, detail, status, created_at) VALUES (:player, 'shop_cart', :amount, :reason, :detail, 'pending', :time)");
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':amount', $finalPrice, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':reason', "购物车结算({$modeName}" . ($colorName ? "-{$colorName}潜影盒" : '') . "): " . implode('、', $reasonItems), SQLITE3_TEXT);
|
||||
$detailData = [
|
||||
'settlement' => $settlement,
|
||||
'rate' => $rate,
|
||||
'subtotal' => $subtotal,
|
||||
'total_price' => $finalPrice,
|
||||
'discount_percent' => (int)$discountPercent,
|
||||
'discount_amount' => (int)$discountAmount,
|
||||
'pay_mode' => $payMode,
|
||||
'items' => $lines,
|
||||
'tx_id' => bin2hex(random_bytes(8))
|
||||
];
|
||||
if ($settlement === 'shulker') {
|
||||
$detailData['shulker_color'] = $shulkerColor;
|
||||
$detailData['color_name'] = $colorName;
|
||||
$detailData['color_fee'] = $colorFee;
|
||||
}
|
||||
$stmt->bindValue(':detail', json_encode($detailData, JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
// 写入sync_requests
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS sync_requests (player_name TEXT PRIMARY KEY, created_at INTEGER NOT NULL)");
|
||||
$stmt2 = $db->prepare("INSERT OR REPLACE INTO sync_requests (player_name, created_at) VALUES (:player, :time)");
|
||||
$stmt2->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt2->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt2->execute();
|
||||
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
try { $db->exec('ROLLBACK'); } catch (Exception $e2) {}
|
||||
error('购物车结算失败: ' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
// ★ 购买主流程提交成功后,再记录收银台订单到独立 orders.db。
|
||||
// 与 web.db 事务解耦:订单写失败仅影响日志/小票,绝不回滚已成功的库存与发药。
|
||||
$orderNo = '';
|
||||
if ($isCashier || $isAdminToken || $isAdminSession) {
|
||||
$operatorType = $isCashier ? 'cashier' : 'admin';
|
||||
$operatorName = $isCashier ? ($cashierRow['username'] ?? '') : 'admin';
|
||||
try {
|
||||
$orderNo = recordCashierOrder([
|
||||
'operator_type' => $operatorType,
|
||||
'operator_name' => $operatorName,
|
||||
'player_name' => $player,
|
||||
'items_detail' => $lines,
|
||||
'subtotal' => $subtotal,
|
||||
'total_price' => $finalPrice,
|
||||
'discount_percent' => (int)$discountPercent,
|
||||
'discount_amount' => (int)$discountAmount,
|
||||
'settlement' => $settlement,
|
||||
'payment_method' => $payMode
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$orderNo = '';
|
||||
debugLog('buy_cart: 订单记录失败(不影响购买): ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 推送小票书给在线玩家(写入 web_admin_changes → Java pollAdminChanges 拾取并发放 Written Book)
|
||||
// 注意:小票推送失败绝不影响已成功的购买主流程与订单记录,单独容错。
|
||||
if (!empty($orderNo) && !empty($player)) {
|
||||
try {
|
||||
pushReceiptBookToPlayer($player, [
|
||||
'order_no' => $orderNo,
|
||||
'order_time' => date('Y-m-d H:i:s'),
|
||||
'order_player' => $player,
|
||||
'operator' => ($isCashier ? ($cashierRow['username'] ?? '') : 'admin'),
|
||||
'settlement' => $settlement,
|
||||
'pay_method' => $payMode,
|
||||
'total_price' => (int)$finalPrice,
|
||||
'items_text' => array_map(function($l) { return $l['name'] . ' x' . $l['amount'] . ' ... ' . $l['line_total'] . '债'; }, $lines)
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
debugLog('buy_cart: 小票书推送失败(不影响购买): ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
debugLog('[buy_cart] 完成, 耗时=' . round((microtime(true) - $__t0) * 1000) . 'ms, orderNo=' . ($orderNo ?? ''));
|
||||
success([
|
||||
'settlement' => $settlement,
|
||||
'mode_name' => $modeName,
|
||||
'rate' => $rate,
|
||||
'items' => $lines,
|
||||
'subtotal' => $subtotal,
|
||||
'total_price' => $finalPrice,
|
||||
'original_price' => $totalPrice,
|
||||
'discount_percent' => (int)$discountPercent,
|
||||
'discount_amount' => (int)$discountAmount,
|
||||
'saved' => $saved,
|
||||
'player' => $player,
|
||||
'order_no' => $orderNo ?? ''
|
||||
], '购物车结算成功');
|
||||
}
|
||||
|
||||
// ===== Weblogin Token验证(用于商城购买)=====
|
||||
function validateWebloginTokenForShop($webToken) {
|
||||
$db = getDB();
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT * FROM weblogin_tokens WHERE web_token = :token");
|
||||
$stmt->bindValue(':token', $webToken, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$row) return false;
|
||||
|
||||
$createdAt = (int)$row['created_at'];
|
||||
$expireSeconds = (int)$row['expire_seconds'];
|
||||
if (time() - $createdAt > $expireSeconds) return false;
|
||||
|
||||
return [
|
||||
'player' => $row['player_name'],
|
||||
'purpose' => 'weblogin',
|
||||
'created_at' => $createdAt,
|
||||
'expires_at' => $createdAt + $expireSeconds
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 推送打包小票书给在线玩家(写入 web_admin_changes → Java 拾取发放 Written Book)=====
|
||||
function pushReceiptBookToPlayer($playerName, $orderData) {
|
||||
try {
|
||||
$db = getDB();
|
||||
// 确保表存在
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_admin_changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
change_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
target_name TEXT DEFAULT '',
|
||||
change_data TEXT DEFAULT '{}',
|
||||
created_at INTEGER DEFAULT 0,
|
||||
acknowledged INTEGER DEFAULT 0,
|
||||
acked_at INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'done'
|
||||
)");
|
||||
$stmt = $db->prepare("INSERT INTO web_admin_changes (change_type, target_id, target_name, change_data, created_at, status)
|
||||
VALUES ('give_receipt_book', '0', :player, :data, :time, 'pending')");
|
||||
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':data', json_encode($orderData, JSON_UNESCAPED_UNICODE), SQLITE3_TEXT);
|
||||
$stmt->bindValue(':time', time(), SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
} catch (Exception $e) {
|
||||
error_log('[小票书] 推送小票书失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,982 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单系统API
|
||||
*
|
||||
* 用户端:提交工单、查看我的工单、查看详情、回复(MD)、撤销
|
||||
* 管理员端:查看所有工单、回复、驳回、分配服务商
|
||||
* 服务商端:抢单大厅、我处理的工单、回复、标记完结
|
||||
*/
|
||||
|
||||
while (ob_get_level() > 0) { @ob_end_clean(); }
|
||||
ob_start();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
// 仅刷新当前文件的OPcache(不要opcache_reset——会重置整个服务器缓存导致竞态条件)
|
||||
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
|
||||
|
||||
register_shutdown_function(function() {
|
||||
while (ob_get_level() > 0) { @ob_end_flush(); }
|
||||
});
|
||||
|
||||
require_once __DIR__ . '/../core.php';
|
||||
// 工单附件图:兰空图床客户端(本文件不含敏感值,凭据在 lsky_keys.php)
|
||||
require_once __DIR__ . '/../lsky.php';
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) { session_start(); }
|
||||
|
||||
$action = getParam('action', '');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
// ===== 用户端 =====
|
||||
case 'create': ticketCreate(); break;
|
||||
case 'my_list': ticketMyList(); break;
|
||||
case 'detail': ticketDetail(); break;
|
||||
case 'reply': ticketReply(); break;
|
||||
case 'withdraw': ticketWithdraw(); break;
|
||||
|
||||
// ===== 管理员端 =====
|
||||
case 'list_all': ticketListAll(); break;
|
||||
case 'admin_reply': ticketAdminReply(); break;
|
||||
case 'reject': ticketReject(); break;
|
||||
case 'assign': ticketAssign(); break;
|
||||
case 'admin_create': ticketAdminCreate(); break;
|
||||
case 'admin_complete': ticketAdminComplete(); break;
|
||||
|
||||
// ===== 服务商端 =====
|
||||
case 'provider_list': ticketProviderList(); break;
|
||||
case 'available': ticketAvailable(); break;
|
||||
case 'grab': ticketGrab(); break;
|
||||
case 'provider_reply': ticketProviderReply(); break;
|
||||
case 'complete': ticketProviderComplete(); break;
|
||||
|
||||
// ===== 通用 =====
|
||||
case 'upload_image': ticketUploadImage(); break;
|
||||
case 'providers': ticketProviders(); break;
|
||||
case 'provider_check': ticketProviderCheck(); break;
|
||||
|
||||
default:
|
||||
error('未知操作: ' . $action);
|
||||
}
|
||||
|
||||
// ★ WAL checkpoint:释放WAL锁,减少database is locked概率
|
||||
try { walCheckpoint(); } catch (\Throwable $ignored) {}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
error('服务器错误: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具函数
|
||||
// ============================================================
|
||||
|
||||
function requireWebToken() {
|
||||
$token = getParam('token');
|
||||
if (!$token) error('缺少token', 401);
|
||||
$info = validateTokenSilent($token);
|
||||
if (!$info) error('token无效或已过期', 401);
|
||||
return $info;
|
||||
}
|
||||
|
||||
function requireAdmin() {
|
||||
if (!isAdminLoggedIn()) error('未登录管理后台', 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为管理员或授权的服务商用户
|
||||
* 管理员通过session认证,用户通过token认证
|
||||
*/
|
||||
function getAuthUser() {
|
||||
if (isAdminLoggedIn()) return ['player' => 'admin', 'role' => 'admin'];
|
||||
return requireWebToken();
|
||||
}
|
||||
|
||||
function isServiceProviderInGame($playerName) {
|
||||
// 优先从Web.db的web_service_providers表读取(Java同步数据)
|
||||
$db = getDB();
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT 1 FROM web_service_providers WHERE player_name = :p AND active = 1");
|
||||
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
|
||||
$row = $stmt->execute()->fetchArray();
|
||||
if ($row !== false) return true;
|
||||
} catch (\Throwable $e) { /* 表可能不存在 */ }
|
||||
|
||||
// 回退:尝试读取Java的login.db(本地开发环境)
|
||||
$loginDbPath = GAME_LOGIN_DB;
|
||||
if (!file_exists($loginDbPath)) return false;
|
||||
try {
|
||||
$loginDb = new SQLite3($loginDbPath);
|
||||
$loginDb->enableExceptions(true);
|
||||
$stmt = $loginDb->prepare("SELECT 1 FROM service_providers WHERE player_name = :p AND active = 1");
|
||||
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray();
|
||||
$loginDb->close();
|
||||
return $row !== false;
|
||||
} catch (Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getServiceProviderRole($playerName) {
|
||||
// 优先从Web.db读取
|
||||
$db = getDB();
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT role FROM web_service_providers WHERE player_name = :p AND active = 1");
|
||||
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
|
||||
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if ($row) return $row['role'];
|
||||
} catch (\Throwable $e) { /* 表可能不存在 */ }
|
||||
|
||||
// 回退:读取Java的login.db
|
||||
$loginDbPath = GAME_LOGIN_DB;
|
||||
if (!file_exists($loginDbPath)) return null;
|
||||
try {
|
||||
$loginDb = new SQLite3($loginDbPath);
|
||||
$loginDb->enableExceptions(true);
|
||||
$stmt = $loginDb->prepare("SELECT role FROM service_providers WHERE player_name = :p AND active = 1");
|
||||
$stmt->bindValue(':p', $playerName, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||
$loginDb->close();
|
||||
return $row ? $row['role'] : null;
|
||||
} catch (Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 单张工单附件图上限
|
||||
define('TICKET_MAX_IMAGES', 3);
|
||||
|
||||
/**
|
||||
* 读取并校验请求里的 images 参数(数组或 JSON 字符串均可),返回 URL 列表。
|
||||
* 每个 URL 必须以自家图床前缀开头(LSKY_ALLOWED_HOST_PREFIX),最多 3 张。
|
||||
*/
|
||||
function normalizeTicketImages() {
|
||||
$raw = getParam('images', []);
|
||||
if (is_string($raw)) $raw = json_decode($raw, true);
|
||||
if ($raw === null || $raw === '') $raw = [];
|
||||
if (!is_array($raw)) error('images 参数格式错误');
|
||||
$prefix = LSKY_ALLOWED_HOST_PREFIX;
|
||||
$out = [];
|
||||
foreach ($raw as $u) {
|
||||
if (!is_string($u)) error('图片地址格式错误');
|
||||
$u = trim($u);
|
||||
if ($u === '') continue;
|
||||
if (strpos($u, $prefix) !== 0) error('不支持的图片地址(仅接受自家图床)');
|
||||
$out[] = $u;
|
||||
if (count($out) >= TICKET_MAX_IMAGES) break;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function encodeTicketImages(array $list) {
|
||||
return json_encode(array_values($list), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
function decodeTicketImages($raw) {
|
||||
if (!is_string($raw) || $raw === '') return [];
|
||||
$d = json_decode($raw, true);
|
||||
return is_array($d) ? $d : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传工单附件图片:转存自家兰空图床,返回可直接引用的 URL。
|
||||
* 认证:管理后台 session 或用户 token(getAuthUser)。
|
||||
* 限制:仅 png/jpg(★ 2026-10-04 起按图片真实内容判定,不看文件名后缀)、单张 ≤2048K;同会话每分钟最多 12 次(防刷)。
|
||||
*/
|
||||
/**
|
||||
* ★ 清洗上传文件名(2026-10-04 任务4)
|
||||
* 全角句点→半角、零宽字符剔除、去老浏览器带的完整路径、去首尾空白。
|
||||
*/
|
||||
function sanitizeImageFileName($name) {
|
||||
// 全角/非法字符 -> 半角合法字符(Windows 会拒收含全角字符的文件名)
|
||||
$name = str_replace(array(
|
||||
"\xe3\x80\x8e", // 全角句点
|
||||
"\xef\xbd\xa1", // 半角浊点
|
||||
"\xe3\x80\x8f", // 全角斜线
|
||||
"\xe2\x80\x8b", // 零宽空格
|
||||
"\xe2\x80\x8c", // 零宽非连接符
|
||||
"\xe2\x80\x8d", // 零宽连接符
|
||||
"\xef\xbb\xbf", // BOM
|
||||
"\xef\xbc\x8c", // 全角 小于号
|
||||
"\xef\xbc\x9e", // 全角 大于号
|
||||
"\xef\xbc\x9a", // 全角 冒号
|
||||
"\xef\xbc\x9f", // 全角 问号
|
||||
"\xef\xbc\x8a", // 全角 星号
|
||||
"\xef\xbc\x87", // 全角 双引号
|
||||
"\xef\xbc\x9c", // 全角 竖线
|
||||
"\xef\xbc\xa0", // 全角空格
|
||||
), array(
|
||||
'.', '.', '/', '', '', '', '',
|
||||
'_', '_', '_', '_', '_', '_', '_', ' ',
|
||||
), $name);
|
||||
// 老浏览器会带完整路径 C:\fakepath\a.png
|
||||
$name = str_replace('\\', '/', $name);
|
||||
$pos = strrpos($name, '/');
|
||||
if ($pos !== false) $name = substr($name, $pos + 1);
|
||||
return trim($name);
|
||||
}
|
||||
|
||||
/** 后缀归一:jpeg / jpe / jfif -> jpg,其余转小写去点 */
|
||||
function normalizeImageExt($ext) {
|
||||
$ext = strtolower(trim((string)$ext));
|
||||
$ext = str_replace('.', '', $ext);
|
||||
if (in_array($ext, array('jpeg', 'jpe', 'jfif', 'jif'), true)) return 'jpg';
|
||||
return $ext;
|
||||
}
|
||||
|
||||
|
||||
function ticketUploadImage() {
|
||||
getAuthUser();
|
||||
|
||||
// 简单限流:同一会话 1 分钟内最多 12 张
|
||||
$now = time();
|
||||
$slot = isset($_SESSION['lsky_up']) && is_array($_SESSION['lsky_up']) ? $_SESSION['lsky_up'] : ['t' => 0, 'n' => 0];
|
||||
if ($now - (int)$slot['t'] > 60) $slot = ['t' => $now, 'n' => 0];
|
||||
if ((int)$slot['n'] >= 12) error('图片上传太频繁,请稍后再试');
|
||||
$slot['n']++;
|
||||
$_SESSION['lsky_up'] = $slot;
|
||||
|
||||
if (empty($_FILES) || empty($_FILES['image'])) {
|
||||
error('未收到图片文件(表单字段名必须是 image)');
|
||||
}
|
||||
$f = $_FILES['image'];
|
||||
if ((int)$f['error'] !== UPLOAD_ERR_OK) {
|
||||
$msgs = [
|
||||
UPLOAD_ERR_INI_SIZE => '图片超过服务器单文件大小上限',
|
||||
UPLOAD_ERR_FORM_SIZE => '图片超过表单大小上限',
|
||||
UPLOAD_ERR_PARTIAL => '图片上传中断,请重试',
|
||||
UPLOAD_ERR_NO_FILE => '未选择文件',
|
||||
UPLOAD_ERR_NO_TMP_DIR => '服务器临时目录缺失',
|
||||
UPLOAD_ERR_CANT_WRITE => '服务器写入临时文件失败',
|
||||
];
|
||||
$m = isset($msgs[$f['error']]) ? $msgs[$f['error']] : ('上传错误码 ' . $f['error']);
|
||||
error($m);
|
||||
}
|
||||
// 文件名清洗:全角句点→半角、去零宽字符、去首尾空白
|
||||
$orig = sanitizeImageFileName(basename((string)$f['name']));
|
||||
// 大小:≤2048K
|
||||
if ((int)$f['size'] > 2048 * 1024) error('图片不能超过 2048K');
|
||||
if (!is_uploaded_file($f['tmp_name'])) error('非法上传');
|
||||
// ★ 2026-10-04 修复:判定「是什么图」只看图片真实内容,不看文件名后缀。
|
||||
// 旧逻辑用 pathinfo 后缀判类型,导致 .jpeg / 全角句点 / 尾部空格 /
|
||||
// 无后缀的真实 png、jpg 全被误判成「不是 png/jpg 图片」。
|
||||
// 这里 getimagesize 认真实格式,防伪造后缀的安全目的依然成立。
|
||||
$info = @getimagesize($f['tmp_name']);
|
||||
if ($info === false) error('无法识别的图片内容(请确认文件确实是 png 或 jpg)');
|
||||
$itype = (int)$info[2];
|
||||
if ($itype === IMAGETYPE_PNG) $realExt = 'png';
|
||||
elseif ($itype === IMAGETYPE_JPEG) $realExt = 'jpg';
|
||||
else error('仅支持 png / jpg 格式');
|
||||
// 后缀归一:jpeg/jpe/jfif 一律当 jpg
|
||||
$ext = normalizeImageExt(pathinfo($orig, PATHINFO_EXTENSION));
|
||||
// 用真实后缀重命名,去掉 .jpeg / 空格 / 全角点等乱七八糟的原名
|
||||
$base = pathinfo($orig, PATHINFO_FILENAME);
|
||||
if ($base === '') $base = 'ticket_image';
|
||||
$orig = mb_substr($base, 0, 60) . '.' . $realExt;
|
||||
|
||||
$r = lskyUploadFile($f['tmp_name'], $orig);
|
||||
if (empty($r['ok'])) error('图片上传失败: ' . (isset($r['msg']) && $r['msg'] !== '' ? $r['msg'] : '未知错误'));
|
||||
|
||||
success(['url' => $r['url'], 'thumbnail' => isset($r['thumb']) ? $r['thumb'] : $r['url']], '上传成功');
|
||||
}
|
||||
|
||||
function ticketCreate() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
|
||||
$type = trim(getParam('type', ''));
|
||||
$title = trim(getParam('title', ''));
|
||||
$description = trim(getParam('description', ''));
|
||||
|
||||
if (empty($type)) error('请选择工单类型');
|
||||
if (empty($title)) error('请输入工单标题');
|
||||
if (mb_strlen($title) > 100) error('标题不能超过100字');
|
||||
if (mb_strlen($description) > 5000) error('描述不能超过5000字');
|
||||
|
||||
$validTypes = ['bug', 'help', 'report', 'apply', 'other'];
|
||||
if (!in_array($type, $validTypes)) error('无效的工单类型');
|
||||
|
||||
$images = normalizeTicketImages(); // 附件图(≤3,必须来自自家图床)
|
||||
|
||||
$db = getDB();
|
||||
$now = time();
|
||||
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("INSERT INTO web_tickets (type, status, requester, title, description, images, created_at, updated_at) VALUES (:type, 'submitted', :player, :title, :desc, :images, :now, :now)");
|
||||
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':title', $title, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':desc', $description, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':images', encodeTicketImages($images), SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$ticketId = $db->lastInsertRowID();
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('创建失败: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
success(['id' => $ticketId], '工单提交成功');
|
||||
}
|
||||
|
||||
function ticketMyList() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
$db = getDB();
|
||||
|
||||
$status = getParam('status', '');
|
||||
$page = max(1, (int)getParam('page', 1));
|
||||
$limit = 20;
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$where = "WHERE requester = :player";
|
||||
$params = [':player' => $player];
|
||||
|
||||
if ($status && $status !== 'all') {
|
||||
$where .= " AND status = :status";
|
||||
$params[':status'] = $status;
|
||||
}
|
||||
|
||||
$countStmt = $db->prepare("SELECT COUNT(*) as total FROM web_tickets $where");
|
||||
foreach ($params as $k => $v) $countStmt->bindValue($k, $v, SQLITE3_TEXT);
|
||||
$total = $countStmt->execute()->fetchArray(SQLITE3_ASSOC)['total'];
|
||||
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets $where ORDER BY created_at DESC LIMIT :limit OFFSET :offset");
|
||||
foreach ($params as $k => $v) $stmt->bindValue($k, $v, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':offset', $offset, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
|
||||
$list = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$list[] = $row;
|
||||
}
|
||||
|
||||
success([
|
||||
'list' => $list,
|
||||
'total' => (int)$total,
|
||||
'page' => $page,
|
||||
'pages' => ceil($total / $limit)
|
||||
]);
|
||||
}
|
||||
|
||||
function ticketDetail() {
|
||||
$id = (int)getParam('id', 0);
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$row) error('工单不存在');
|
||||
|
||||
// 权限检查:管理员可以直接查看,普通用户只能看自己的工单
|
||||
if (isAdminLoggedIn()) {
|
||||
// 管理员,直接放行
|
||||
} else {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
if ($row['requester'] !== $player && $row['assigned_to'] !== $player) {
|
||||
error('无权查看此工单');
|
||||
}
|
||||
}
|
||||
|
||||
// 获取回复列表
|
||||
$replies = getTicketReplies($id);
|
||||
|
||||
$row['replies'] = $replies;
|
||||
// 附件图:老库/老工单没有 images 列时兜底为空数组
|
||||
$row['images'] = decodeTicketImages(isset($row['images']) ? $row['images'] : null);
|
||||
success($row);
|
||||
}
|
||||
|
||||
function ticketReply() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
$id = (int)getParam('id', 0);
|
||||
$message = trim(getParam('message', ''));
|
||||
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
if (empty($message)) error('回复内容不能为空');
|
||||
if (mb_strlen($message) > 5000) error('回复内容不能超过5000字');
|
||||
|
||||
$db = getDB();
|
||||
|
||||
// 检查工单状态和权限
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$ticket) error('工单不存在');
|
||||
if ($ticket['requester'] !== $player) error('只能回复自己的工单');
|
||||
if (in_array($ticket['status'], ['withdrawn', 'rejected'])) error('该工单已关闭,无法回复');
|
||||
|
||||
$now = time();
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
// 插入回复
|
||||
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, :sender, 'user', :msg, :now)");
|
||||
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':sender', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':msg', $message, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
// 更新工单状态为已回复
|
||||
$stmt = $db->prepare("UPDATE web_tickets SET status = 'replied', updated_at = :now WHERE id = :id");
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('回复失败');
|
||||
}
|
||||
|
||||
success(null, '回复成功');
|
||||
}
|
||||
|
||||
function ticketWithdraw() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
$id = (int)getParam('id', 0);
|
||||
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$ticket) error('工单不存在');
|
||||
if ($ticket['requester'] !== $player) error('无权操作此工单');
|
||||
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
|
||||
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("UPDATE web_tickets SET status = 'withdrawn', updated_at = :now WHERE id = :id");
|
||||
$stmt->bindValue(':now', time(), SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('撤销失败');
|
||||
}
|
||||
|
||||
success(null, '工单已撤销');
|
||||
}
|
||||
|
||||
// ===== 管理员端 =====
|
||||
|
||||
function ticketListAll() {
|
||||
requireAdmin();
|
||||
$db = getDB();
|
||||
|
||||
// 兜底建表(服务器core.php可能版本旧)
|
||||
try {
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'submitted',
|
||||
requester TEXT NOT NULL,
|
||||
assigned_to TEXT DEFAULT '',
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
images TEXT DEFAULT '[]',
|
||||
reject_reason TEXT DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)");
|
||||
// ★ 迁移:老库补 images 列(工单附件图 URL 数组 JSON)
|
||||
try {
|
||||
$db->exec("ALTER TABLE web_tickets ADD COLUMN images TEXT DEFAULT '[]'");
|
||||
} catch (Exception $e) { /* 列已存在 */ }
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_ticket_replies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
sender TEXT NOT NULL,
|
||||
role TEXT DEFAULT 'user',
|
||||
message TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_wt_requester ON web_tickets(requester)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_wt_status ON web_tickets(status)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_wt_assigned ON web_tickets(assigned_to)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_wtr_ticket ON web_ticket_replies(ticket_id)");
|
||||
} catch (\Throwable $e) {
|
||||
error('工单表初始化失败: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
$status = getParam('status', 'all');
|
||||
$page = max(1, (int)getParam('page', 1));
|
||||
$search = trim(getParam('search', ''));
|
||||
$limit = 20;
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$where = "WHERE 1=1";
|
||||
$params = [];
|
||||
|
||||
if ($status && $status !== 'all') {
|
||||
$where .= " AND status = :status";
|
||||
$params[':status'] = $status;
|
||||
}
|
||||
if ($search) {
|
||||
$where .= " AND (requester LIKE :search OR title LIKE :search2)";
|
||||
$params[':search'] = "%$search%";
|
||||
$params[':search2'] = "%$search%";
|
||||
}
|
||||
|
||||
$countStmt = $db->prepare("SELECT COUNT(*) as total FROM web_tickets $where");
|
||||
foreach ($params as $k => $v) $countStmt->bindValue($k, $v, SQLITE3_TEXT);
|
||||
$total = $countStmt->execute()->fetchArray(SQLITE3_ASSOC)['total'];
|
||||
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets $where ORDER BY created_at DESC LIMIT :limit OFFSET :offset");
|
||||
foreach ($params as $k => $v) $stmt->bindValue($k, $v, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':limit', $limit, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':offset', $offset, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
|
||||
$list = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$list[] = $row;
|
||||
}
|
||||
|
||||
// 统计各状态数量
|
||||
$stats = [];
|
||||
foreach (['submitted', 'replied', 'completed', 'withdrawn', 'rejected'] as $s) {
|
||||
$cnt = $db->querySingle("SELECT COUNT(*) FROM web_tickets WHERE status = '$s'");
|
||||
$stats[$s] = (int)$cnt;
|
||||
}
|
||||
$stats['all'] = (int)$db->querySingle("SELECT COUNT(*) FROM web_tickets");
|
||||
|
||||
success([
|
||||
'list' => $list,
|
||||
'total' => (int)$total,
|
||||
'page' => $page,
|
||||
'pages' => ceil($total / $limit),
|
||||
'stats' => $stats
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
error('查询工单失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function ticketAdminReply() {
|
||||
requireAdmin();
|
||||
$id = (int)getParam('id', 0);
|
||||
$message = trim(getParam('message', ''));
|
||||
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
if (empty($message)) error('回复内容不能为空');
|
||||
if (mb_strlen($message) > 5000) error('回复内容不能超过5000字');
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$ticket) error('工单不存在');
|
||||
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
|
||||
|
||||
$now = time();
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, 'admin', 'admin', :msg, :now)");
|
||||
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':msg', $message, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
// 管理员回复 → 状态更新为已回复(不改变assigned_to,保留原服务商所有权)
|
||||
$stmt = $db->prepare("UPDATE web_tickets SET status = 'replied', updated_at = :now WHERE id = :id");
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('回复失败');
|
||||
}
|
||||
|
||||
success(null, '回复成功');
|
||||
}
|
||||
|
||||
function ticketReject() {
|
||||
requireAdmin();
|
||||
$id = (int)getParam('id', 0);
|
||||
$reason = trim(getParam('reason', ''));
|
||||
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
if (empty($reason)) error('请输入驳回原因');
|
||||
if (mb_strlen($reason) > 500) error('驳回原因不能超过500字');
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$ticket) error('工单不存在');
|
||||
if ($ticket['status'] === 'withdrawn' || $ticket['status'] === 'rejected') error('该工单已关闭');
|
||||
|
||||
$now = time();
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("UPDATE web_tickets SET status = 'rejected', reject_reason = :reason, updated_at = :now WHERE id = :id");
|
||||
$stmt->bindValue(':reason', $reason, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('驳回失败');
|
||||
}
|
||||
|
||||
success(null, '工单已驳回');
|
||||
}
|
||||
|
||||
function ticketAssign() {
|
||||
requireAdmin();
|
||||
$id = (int)getParam('id', 0);
|
||||
$provider = trim(getParam('provider', ''));
|
||||
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
if (empty($provider)) error('请输入服务商名称');
|
||||
|
||||
// 验证是否为服务商
|
||||
if (!isServiceProviderInGame($provider)) error("$provider 不是服务商");
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$ticket) error('工单不存在');
|
||||
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
|
||||
|
||||
$now = time();
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("UPDATE web_tickets SET assigned_to = :provider, status = 'replied', updated_at = :now WHERE id = :id");
|
||||
$stmt->bindValue(':provider', $provider, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('分配失败');
|
||||
}
|
||||
|
||||
success(null, "已分配给 $provider");
|
||||
}
|
||||
|
||||
function ticketAdminCreate() {
|
||||
requireAdmin();
|
||||
$type = trim(getParam('type', ''));
|
||||
$title = trim(getParam('title', ''));
|
||||
$description = trim(getParam('description', ''));
|
||||
$provider = trim(getParam('provider', ''));
|
||||
|
||||
if (empty($type)) error('请选择工单类型');
|
||||
if (empty($title)) error('请输入工单标题');
|
||||
if (mb_strlen($title) > 100) error('标题不能超过100字');
|
||||
if (mb_strlen($description) > 5000) error('描述不能超过5000字');
|
||||
|
||||
$validTypes = ['bug', 'help', 'report', 'apply', 'other'];
|
||||
if (!in_array($type, $validTypes)) error('无效的工单类型');
|
||||
|
||||
// 如果指定了服务商,验证身份
|
||||
if ($provider && !isServiceProviderInGame($provider)) {
|
||||
error("$provider 不是服务商");
|
||||
}
|
||||
|
||||
$images = normalizeTicketImages(); // 附件图(≤3,必须来自自家图床)
|
||||
|
||||
$db = getDB();
|
||||
$now = time();
|
||||
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("INSERT INTO web_tickets (type, status, requester, assigned_to, title, description, images, created_at, updated_at) VALUES (:type, 'replied', 'admin', :provider, :title, :desc, :images, :now, :now)");
|
||||
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':provider', $provider, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':title', $title, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':desc', $description, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':images', encodeTicketImages($images), SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$ticketId = $db->lastInsertRowID();
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('创建失败: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$msg = '工单已创建';
|
||||
if ($provider) $msg .= ",已派发给 $provider";
|
||||
success(['id' => $ticketId], $msg);
|
||||
}
|
||||
|
||||
function ticketAdminComplete() {
|
||||
requireAdmin();
|
||||
$id = (int)getParam('id', 0);
|
||||
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$ticket) error('工单不存在');
|
||||
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
|
||||
|
||||
$now = time();
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, 'admin', 'admin', '✅ 工单已由管理员标记完结', :now)");
|
||||
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$stmt = $db->prepare("UPDATE web_tickets SET status = 'completed', updated_at = :now WHERE id = :id");
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('操作失败');
|
||||
}
|
||||
|
||||
success(null, '已标记完结');
|
||||
}
|
||||
|
||||
// ===== 服务商端 =====
|
||||
|
||||
function ticketProviderList() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
|
||||
// 验证服务商身份
|
||||
$role = getServiceProviderRole($player);
|
||||
if (!$role) error('你不是服务商');
|
||||
|
||||
$db = getDB();
|
||||
$type = getParam('type', 'all');
|
||||
// processing: 活跃(已提交/已回复)
|
||||
// completed: 已完结/已撤销/已驳回
|
||||
// all: 全部跟过的工单
|
||||
|
||||
$where = "WHERE assigned_to = :player";
|
||||
$params = [':player' => $player];
|
||||
|
||||
if ($type === 'processing') {
|
||||
$where .= " AND status IN ('submitted', 'replied')";
|
||||
} elseif ($type === 'completed') {
|
||||
$where .= " AND status IN ('completed', 'withdrawn', 'rejected')";
|
||||
}
|
||||
// 'all' 不加status过滤
|
||||
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets $where ORDER BY updated_at DESC LIMIT 100");
|
||||
foreach ($params as $k => $v) $stmt->bindValue($k, $v, SQLITE3_TEXT);
|
||||
$result = $stmt->execute();
|
||||
|
||||
$list = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$list[] = $row;
|
||||
}
|
||||
|
||||
success(['list' => $list, 'role' => $role]);
|
||||
}
|
||||
|
||||
function ticketAvailable() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
|
||||
if (!isServiceProviderInGame($player)) error('你不是服务商');
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE status = 'submitted' AND assigned_to = '' ORDER BY created_at DESC LIMIT 50");
|
||||
$result = $stmt->execute();
|
||||
|
||||
$list = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$list[] = $row;
|
||||
}
|
||||
|
||||
success(['list' => $list]);
|
||||
}
|
||||
|
||||
function ticketGrab() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
$id = (int)getParam('id', 0);
|
||||
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
if (!isServiceProviderInGame($player)) error('你不是服务商');
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$ticket) error('工单不存在');
|
||||
if ($ticket['status'] !== 'submitted') error('该工单已被接单');
|
||||
if (!empty($ticket['assigned_to'])) error('该工单已被分配');
|
||||
|
||||
$now = time();
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("UPDATE web_tickets SET assigned_to = :provider, updated_at = :now WHERE id = :id");
|
||||
$stmt->bindValue(':provider', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('抢单失败');
|
||||
}
|
||||
|
||||
success(null, '抢单成功');
|
||||
}
|
||||
|
||||
function ticketProviderReply() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
$id = (int)getParam('id', 0);
|
||||
$message = trim(getParam('message', ''));
|
||||
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
if (empty($message)) error('回复内容不能为空');
|
||||
if (mb_strlen($message) > 5000) error('回复内容不能超过5000字');
|
||||
if (!isServiceProviderInGame($player)) error('你不是服务商');
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$ticket) error('工单不存在');
|
||||
if ($ticket['assigned_to'] !== $player) error('这不是分配给你的工单');
|
||||
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
|
||||
|
||||
$now = time();
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, :sender, 'provider', :msg, :now)");
|
||||
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':sender', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':msg', $message, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$stmt = $db->prepare("UPDATE web_tickets SET status = 'replied', updated_at = :now WHERE id = :id");
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('回复失败');
|
||||
}
|
||||
|
||||
success(null, '回复成功');
|
||||
}
|
||||
|
||||
function ticketProviderComplete() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
$id = (int)getParam('id', 0);
|
||||
|
||||
if ($id <= 0) error('无效的工单ID');
|
||||
if (!isServiceProviderInGame($player)) error('你不是服务商');
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id");
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
if (!$ticket) error('工单不存在');
|
||||
if ($ticket['assigned_to'] !== $player) error('这不是分配给你的工单');
|
||||
if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭');
|
||||
|
||||
$now = time();
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
// 插入完结回复
|
||||
$stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, :sender, 'provider', '✅ 工单已处理完毕', :now)");
|
||||
$stmt->bindValue(':tid', $id, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':sender', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
// 状态改为completed
|
||||
$stmt = $db->prepare("UPDATE web_tickets SET status = 'completed', updated_at = :now WHERE id = :id");
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
$db->exec('COMMIT');
|
||||
} catch (Exception $e) {
|
||||
$db->exec('ROLLBACK');
|
||||
error('操作失败');
|
||||
}
|
||||
|
||||
success(null, '已标记完结');
|
||||
}
|
||||
|
||||
// ===== 通用 =====
|
||||
|
||||
function ticketProviders() {
|
||||
if (!isAdminLoggedIn()) error('未登录管理后台');
|
||||
|
||||
$loginDbPath = GAME_LOGIN_DB;
|
||||
if (!file_exists($loginDbPath)) success(['list' => []]);
|
||||
|
||||
try {
|
||||
$loginDb = new SQLite3($loginDbPath);
|
||||
$result = $loginDb->query("SELECT player_name, role, join_time FROM service_providers WHERE active = 1");
|
||||
$list = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$list[] = $row;
|
||||
}
|
||||
$loginDb->close();
|
||||
success(['list' => $list]);
|
||||
} catch (Exception $e) {
|
||||
success(['list' => []]);
|
||||
}
|
||||
}
|
||||
|
||||
function ticketProviderCheck() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
$isProvider = isServiceProviderInGame($player);
|
||||
$role = $isProvider ? getServiceProviderRole($player) : null;
|
||||
success(['is_provider' => $isProvider, 'role' => $role]);
|
||||
}
|
||||
|
||||
// ===== 辅助函数 =====
|
||||
|
||||
function getTicketReplies($ticketId) {
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT * FROM web_ticket_replies WHERE ticket_id = :tid ORDER BY created_at ASC");
|
||||
$stmt->bindValue(':tid', $ticketId, SQLITE3_INTEGER);
|
||||
$result = $stmt->execute();
|
||||
|
||||
$list = [];
|
||||
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
|
||||
$list[] = $row;
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
/**
|
||||
* 玩家 2FA (TOTP) 二维码展示中转 + 绑定状态双向同步(第47批)—— PHP 只做显示器与镜像
|
||||
*
|
||||
* 2FA 的动态码校验逻辑在 Java 端与 PHP 端各自本地完成(同一 Base32 密钥),
|
||||
* 这里负责:
|
||||
* 1) 暂存 otpauth 载荷供玩家浏览器渲染二维码,10 分钟自动过期
|
||||
* 2) 以 web_account_2fa 为持久镜像,供 Java 端拉取/推送绑定状态
|
||||
* (修复:网页端绑定后游戏内 /2fa 显示"未绑定")
|
||||
*
|
||||
* Java -> PHP(secret 校验):
|
||||
* push_qr 存一条二维码载荷(token -> otpauth URI)
|
||||
* pull_state 拉取该玩家网页端绑定状态(bound/secret/bound_at/updated_at)
|
||||
* push_state 推送 Java 端绑定/解绑结果(网页端无记录或记录更旧时落库)
|
||||
* 玩家浏览器(持有随机 token 即可读):
|
||||
* get 按 t 取回 otpauth URI
|
||||
*
|
||||
* 同步语义见 Java 端 TwoFactorManager.applyWebState():
|
||||
* 单向导入(网页绑定游戏未绑定)/ 解绑传播(网页解绑且更晚)/
|
||||
* 密钥冲突取时间新者;恢复代码两端格式不同(网页16位明文 vs 游戏6位哈希),各自维护。
|
||||
*/
|
||||
|
||||
while (ob_get_level() > 0) { @ob_end_clean(); }
|
||||
ob_start();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); }
|
||||
|
||||
require_once __DIR__ . '/../core.php';
|
||||
|
||||
/** 载荷有效期(秒),与 Java 端 twofa_pending_at 的 10 分钟对齐 */
|
||||
define('TWOFA_QR_TTL', 600);
|
||||
|
||||
function twofaEnsureTable($db) {
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_twofa_qr (
|
||||
token TEXT PRIMARY KEY,
|
||||
player TEXT NOT NULL,
|
||||
otpauth TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)");
|
||||
}
|
||||
|
||||
function twofaClean($db) {
|
||||
$db->exec("DELETE FROM web_twofa_qr WHERE expires_at < " . time());
|
||||
}
|
||||
|
||||
/** Java 侧通信校验(与 api/sn.php 同款) */
|
||||
function twofaRequireSecret() {
|
||||
$secret = getParam('secret', '');
|
||||
if ($secret === '' || $secret !== SECRET_KEY) {
|
||||
error('密钥错误', 403);
|
||||
}
|
||||
}
|
||||
|
||||
/** token 形态:64 位十六进制(Java SecureRandom 256bit)。
|
||||
* WAF 实测(2026-10-01 矩阵含重复验证):查询参数名 `token` + ≥32位纯hex 会被
|
||||
* CF 源站 404 拦截;参数名 `t` 任意长度(16/32/48/64)均可过,POST body 亦可过。
|
||||
* 故取数接口固定用 `t=`(`token=` 仅作兼容回退)。 */
|
||||
function twofaValidToken($t) {
|
||||
return is_string($t) && preg_match('/^[a-f0-9]{32,128}$/', $t) === 1;
|
||||
}
|
||||
|
||||
$action = getParam('action', '');
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
|
||||
// ===== Java -> PHP:存二维码载荷 =====
|
||||
case 'push_qr': {
|
||||
twofaRequireSecret();
|
||||
$token = (string) getParam('token', '');
|
||||
$player = (string) getParam('player', '');
|
||||
$otpauth = (string) getParam('otpauth', '');
|
||||
|
||||
if (!twofaValidToken($token)) error('token格式错误', 400);
|
||||
if ($player === '' || strlen($player) > 32) error('玩家名不合法', 400);
|
||||
if (strpos($otpauth, 'otpauth://totp/') !== 0 || strlen($otpauth) > 512) {
|
||||
error('otpauth载荷不合法', 400);
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
twofaEnsureTable($db);
|
||||
twofaClean($db);
|
||||
|
||||
$stmt = $db->prepare("INSERT OR REPLACE INTO web_twofa_qr
|
||||
(token, player, otpauth, expires_at) VALUES (:t, :p, :o, :e)");
|
||||
$stmt->bindValue(':t', $token, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':o', $otpauth, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':e', time() + TWOFA_QR_TTL, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
|
||||
success(['expires_in' => TWOFA_QR_TTL], 'stored');
|
||||
break;
|
||||
}
|
||||
|
||||
// ===== 玩家浏览器:取二维码载荷 =====
|
||||
case 'get': {
|
||||
// 主参数名必须是 t:`token=<32+位hex>` 会被源站 WAF 404,`t=<hex>` 实测全长度可过
|
||||
$token = (string) getParam('t', '');
|
||||
if ($token === '') $token = (string) getParam('token', '');
|
||||
if (!twofaValidToken($token)) error('参数错误', 400);
|
||||
|
||||
$db = getDB();
|
||||
twofaEnsureTable($db);
|
||||
twofaClean($db);
|
||||
|
||||
$stmt = $db->prepare("SELECT player, otpauth, expires_at
|
||||
FROM web_twofa_qr WHERE token = :t");
|
||||
$stmt->bindValue(':t', $token, SQLITE3_TEXT);
|
||||
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$row) error('二维码不存在或已过期', 404);
|
||||
|
||||
success([
|
||||
'player' => $row['player'],
|
||||
'otpauth' => $row['otpauth'],
|
||||
'expires_at' => (int) $row['expires_at'],
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
// ===== Java -> PHP:拉取网页端 2FA 绑定状态(第47批双向同步) =====
|
||||
case 'pull_state': {
|
||||
twofaRequireSecret();
|
||||
$player = (string) getParam('player', '');
|
||||
if ($player === '' || strlen($player) > 32) error('玩家名不合法', 400);
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT totp_secret, bound_at, updated_at
|
||||
FROM web_account_2fa WHERE player_name = :p");
|
||||
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
|
||||
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
success(['exists' => false, 'bound' => false,
|
||||
'secret' => '', 'bound_at' => 0, 'updated_at' => 0]);
|
||||
}
|
||||
$bound = ((int) $row['bound_at'] > 0 && (string) $row['totp_secret'] !== '');
|
||||
success([
|
||||
'exists' => true,
|
||||
'bound' => $bound,
|
||||
// 明文密钥仅回给持 SECRET_KEY 的 Java 服务端,绝不出现在浏览器接口
|
||||
'secret' => $bound ? (string) $row['totp_secret'] : '',
|
||||
'bound_at' => (int) $row['bound_at'],
|
||||
'updated_at' => (int) $row['updated_at'],
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
// ===== Java -> PHP:推送 Java 端绑定/解绑结果(第47批双向同步) =====
|
||||
case 'push_state': {
|
||||
twofaRequireSecret();
|
||||
$player = (string) getParam('player', '');
|
||||
$bound = (string) getParam('bound', '') === '1';
|
||||
// ★ TOTP 密钥参数名必须是 twofa_secret:'secret' 已被通信密钥占用
|
||||
$secret = (string) getParam('twofa_secret', '');
|
||||
$boundAt = (int) getParam('bound_at', '0');
|
||||
if ($player === '' || strlen($player) > 32) error('玩家名不合法', 400);
|
||||
if ($bound && !preg_match('/^[A-Z2-7]{16,64}$/', $secret)) {
|
||||
error('密钥格式错误', 400);
|
||||
}
|
||||
|
||||
$db = getDB();
|
||||
$stmt = $db->prepare("SELECT bound_at, recovery_codes
|
||||
FROM web_account_2fa WHERE player_name = :p");
|
||||
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
|
||||
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
|
||||
$existingAt = $row ? (int) $row['bound_at'] : -1; // -1 = 无记录
|
||||
|
||||
$now = time();
|
||||
if ($bound) {
|
||||
// 仅当网页端无记录、未绑定、或绑定时间更旧时覆盖;
|
||||
// 网页端已绑定且不比我们旧 → 不动(网页端用户主动操作优先)
|
||||
$webNewer = ($existingAt > 0 && $boundAt > 0 && $existingAt >= $boundAt);
|
||||
if ($webNewer) {
|
||||
success(['applied' => false, 'reason' => 'web_side_newer']);
|
||||
}
|
||||
// 游戏端恢复代码是 SHA-256 哈希且格式与网页端(16位明文 JSON)不兼容,
|
||||
// 推送时清空网页端恢复代码列表,避免网页端用旧代码通过第二层验证
|
||||
$stmt = $db->prepare("INSERT INTO web_account_2fa
|
||||
(player_name, totp_secret, recovery_codes, bound_at, updated_at)
|
||||
VALUES (:p, :s, '', :b, :u)
|
||||
ON CONFLICT(player_name) DO UPDATE SET
|
||||
totp_secret = :s, recovery_codes = '',
|
||||
bound_at = :b, updated_at = :u");
|
||||
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':s', $secret, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':b', $boundAt > 0 ? $boundAt : $now, SQLITE3_INTEGER);
|
||||
$stmt->bindValue(':u', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
success(['applied' => true, 'action' => 'bound']);
|
||||
} else {
|
||||
// 解绑:仅当网页端记录不比我们新时清空(防覆盖网页端更晚的重新绑定)
|
||||
$webNewer = ($existingAt > 0 && $boundAt > 0 && $existingAt > $boundAt);
|
||||
if ($webNewer) {
|
||||
success(['applied' => false, 'reason' => 'web_side_newer']);
|
||||
}
|
||||
if ($existingAt === 0) {
|
||||
success(['applied' => false, 'reason' => 'already_unbound']);
|
||||
}
|
||||
if ($existingAt > 0) {
|
||||
$stmt = $db->prepare("UPDATE web_account_2fa SET
|
||||
totp_secret = '', recovery_codes = '', bound_at = 0, updated_at = :u
|
||||
WHERE player_name = :p");
|
||||
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':u', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
}
|
||||
success(['applied' => true, 'action' => 'unbound']);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
error('未知操作', 400);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
debugLog('twofa异常: ' . $e->getMessage(), ['action' => $action]);
|
||||
error('服务器内部错误', 500);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
/**
|
||||
* 自定义背景壁纸源代理 —— 第46批创建,第47批改多源 fallback
|
||||
*
|
||||
* 用户端(player.php)与管理员端(admin.php)的自定义背景系统共用:
|
||||
* action=bing 每日壁纸元数据(图片URL/标题/版权/日期),6小时文件缓存
|
||||
* 第47批:单一必应源 → 四级 fallback 链(用户侧必应取不到图的对策):
|
||||
* 1) www.bing.com HPImageArchive 2) cn.bing.com 镜像
|
||||
* 3) NASA APOD (api.nasa.gov) 4) picsum.photos 兜底(永不失败)
|
||||
* action=image 按 u 参数转发图片二进制(白名单域,防SSRF/防盗链),浏览器缓存6小时
|
||||
* 第47批:白名单随多源扩充 NASA / picsum / unsplash 域
|
||||
*
|
||||
* 自定义图片URL模式为浏览器直连(前端 background-image),不经过本代理。
|
||||
* 自包含轻量实现,不依赖 core.php/DB。
|
||||
*/
|
||||
|
||||
while (ob_get_level() > 0) { @ob_end_clean(); }
|
||||
ob_start();
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
/** 壁纸元数据缓存时长(秒)—— 每日换图,6小时足够 */
|
||||
define('WPJ_BING_TTL', 21600);
|
||||
|
||||
function wpjOut($ok, $msg, $data = null) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: no-store');
|
||||
echo json_encode(array('success' => $ok, 'message' => $msg, 'data' => $data),
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
/** 缓存文件放系统临时目录 —— Web 根可能只读,临时目录必可写 */
|
||||
function wpjCacheFile($key) {
|
||||
return rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'sdf1_wp_' . $key . '.json';
|
||||
}
|
||||
|
||||
function wpjCacheGet($key, $ttl) {
|
||||
$f = wpjCacheFile($key);
|
||||
if (is_file($f) && (time() - filemtime($f)) < $ttl) {
|
||||
$j = json_decode((string)@file_get_contents($f), true);
|
||||
if (is_array($j)) { return $j; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function wpjCacheSet($key, $arr) {
|
||||
@file_put_contents(wpjCacheFile($key),
|
||||
json_encode($arr, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
function wpjFetch($url, $timeout, $referer) {
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, array(
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => 4,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) sdf1-wallpaper',
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_REFERER => $referer,
|
||||
));
|
||||
$body = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
return is_string($body) ? $body : '';
|
||||
}
|
||||
$ctx = stream_context_create(array('http' => array(
|
||||
'timeout' => $timeout,
|
||||
'header' => "User-Agent: Mozilla/5.0 sdf1-wallpaper\r\nReferer: " . $referer . "\r\n",
|
||||
)));
|
||||
$body = @file_get_contents($url, false, $ctx);
|
||||
return is_string($body) ? $body : '';
|
||||
}
|
||||
|
||||
$act = isset($_GET['action']) ? $_GET['action'] : '';
|
||||
|
||||
/* ===== 每日壁纸元数据(第47批:多源 fallback 链) ===== */
|
||||
if ($act === 'bing') {
|
||||
// ★第48批#247:key 版本化 bing -> bing_v2(旧缓存存着无 api/ 前缀的坏 URL,须失效)
|
||||
$cached = wpjCacheGet('bing_v2', WPJ_BING_TTL);
|
||||
if ($cached !== null) { wpjOut(true, 'ok', $cached); }
|
||||
|
||||
$data = null;
|
||||
|
||||
// 源1/2:必应官方 HPImageArchive(主域失败换中国镜像)
|
||||
foreach (array('https://www.bing.com', 'https://cn.bing.com') as $base) {
|
||||
$body = wpjFetch($base . '/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=zh-CN', 6, $base . '/');
|
||||
$j = json_decode($body, true);
|
||||
if (!is_array($j) || empty($j['images'][0]['urlbase'])) { continue; }
|
||||
$img = $j['images'][0];
|
||||
$direct = $base . $img['urlbase'] . '_1920x1080.jpg';
|
||||
$data = array(
|
||||
// 前端优先用代理地址(防防盗链),direct 作兜底直连
|
||||
// ★第48批#247:补 api/ 前缀(原相对路径经前端路由转发后 404,图片取不到)
|
||||
'url' => 'api/wallpaper.php?action=image&u=' . rawurlencode($direct),
|
||||
'direct' => $direct,
|
||||
'source' => 'bing',
|
||||
'title' => isset($img['title']) ? $img['title'] : '',
|
||||
'copyright' => isset($img['copyright']) ? $img['copyright'] : '',
|
||||
'date' => isset($img['startdate']) ? $img['startdate'] : '',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// 源3:NASA 每日天文图 APOD(DEMO_KEY 限速,但有6小时缓存,服务器低频调用足够)
|
||||
if ($data === null) {
|
||||
$body = wpjFetch('https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY&hd=true', 8, 'https://api.nasa.gov/');
|
||||
$j = json_decode($body, true);
|
||||
if (is_array($j) && !empty($j['hdurl']) && preg_match('#^https?://#i', $j['hdurl'])) {
|
||||
$direct = $j['hdurl'];
|
||||
$data = array(
|
||||
// ★第48批#247:补 api/ 前缀(同 bing)
|
||||
'url' => 'api/wallpaper.php?action=image&u=' . rawurlencode($direct),
|
||||
'direct' => $direct,
|
||||
'source' => 'nasa',
|
||||
'title' => isset($j['title']) ? $j['title'] : 'NASA 每日天文图',
|
||||
'copyright' => isset($j['copyright']) ? $j['copyright'] : 'NASA APOD',
|
||||
'date' => isset($j['date']) ? $j['date'] : date('Y-m-d'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 源4:picsum 固定尺寸摄影图(无 key、永不应答失败的最后兜底;302 跳转浏览器直接可用)
|
||||
if ($data === null) {
|
||||
$direct = 'https://picsum.photos/1920/1080';
|
||||
$data = array(
|
||||
'url' => $direct,
|
||||
'direct' => $direct,
|
||||
'source' => 'picsum',
|
||||
'title' => '随机摄影图',
|
||||
'copyright' => 'Photos from Unsplash (picsum.photos)',
|
||||
'date' => date('Y-m-d'),
|
||||
);
|
||||
}
|
||||
|
||||
wpjCacheSet('bing_v2', $data);
|
||||
wpjOut(true, 'ok', $data);
|
||||
}
|
||||
|
||||
/* ===== 图片转发(白名单域,防 SSRF;第47批随多源扩充) ===== */
|
||||
if ($act === 'image') {
|
||||
$u = isset($_GET['u']) ? (string)$_GET['u'] : '';
|
||||
$host = @parse_url($u, PHP_URL_HOST);
|
||||
if ($u === '' || !is_string($host) || $host === '') { wpjOut(false, '参数无效'); }
|
||||
$host = strtolower($host);
|
||||
$allow = array(
|
||||
// 必应(源1/2)
|
||||
'www.bing.com', 'bing.com', 'th.bing.com', 'cn.bing.com',
|
||||
// NASA APOD(源3,hdurl 指向 apod.nasa.gov / www.nasa.gov 等)
|
||||
'apod.nasa.gov', 'www.nasa.gov', 'images-api.nasa.gov',
|
||||
// picsum 兜底链(源4,302 目标域)
|
||||
'picsum.photos', 'fastly.picsum.photos', 'images.unsplash.com',
|
||||
);
|
||||
if (!in_array($host, $allow, true)) { wpjOut(false, '不支持的图片源'); }
|
||||
|
||||
$referer = 'https://www.bing.com/';
|
||||
if ($host === 'apod.nasa.gov' || $host === 'www.nasa.gov' || $host === 'images-api.nasa.gov') {
|
||||
$referer = 'https://apod.nasa.gov/';
|
||||
} elseif ($host === 'picsum.photos' || $host === 'fastly.picsum.photos' || $host === 'images.unsplash.com') {
|
||||
$referer = 'https://picsum.photos/';
|
||||
}
|
||||
$bin = wpjFetch($u, 10, $referer);
|
||||
if (!is_string($bin) || strlen($bin) < 1000) { wpjOut(false, '图片下载失败'); }
|
||||
// 粗校验图片魔数,防返回HTML错误页被当图片缓存
|
||||
$head = substr($bin, 0, 3);
|
||||
if ($head !== "\xFF\xD8\xFF" && substr($bin, 0, 4) !== "\x89PNG" && stripos(substr($bin, 0, 32), 'RIFF') !== 0) {
|
||||
wpjOut(false, '图片源返回内容异常');
|
||||
}
|
||||
header('Content-Type: image/jpeg');
|
||||
header('Cache-Control: public, max-age=21600');
|
||||
echo $bin;
|
||||
exit;
|
||||
}
|
||||
|
||||
wpjOut(false, '未知 action');
|
||||
@@ -0,0 +1,494 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?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
|
||||
@@ -0,0 +1,511 @@
|
||||
<?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>
|
||||
| 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>
|
||||
| Key:<b><?php echo htmlspecialchars(maskKey($K['v_key']), ENT_QUOTES); ?></b>
|
||||
| scene:<b><?php echo (int) $K['v_scene']; ?></b><br>
|
||||
<span style="color:#6b7695">优先级:captcha_keys.php > config.php 常量 > URL参数 > 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&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&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>
|
||||
@@ -0,0 +1,378 @@
|
||||
// ===== 收银台(管理员/收银员为玩家代购)=====
|
||||
// 与 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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,798 @@
|
||||
<?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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||
@@ -0,0 +1,125 @@
|
||||
<?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
|
||||
@@ -0,0 +1,398 @@
|
||||
<?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>';
|
||||
}
|
||||
@@ -0,0 +1,845 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,104 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 109 KiB |
@@ -0,0 +1,118 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,170 @@
|
||||
<?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'))
|
||||
@@ -0,0 +1,414 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,165 @@
|
||||
<?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 无效或已过期)'];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?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');
|
||||
@@ -0,0 +1,256 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,313 @@
|
||||
<?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 = {'&':'&','<':'<','>':'>','"':'"'}; 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, "&")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,34 @@
|
||||
## 充值订单编号配置模块
|
||||
|
||||
1. 本文件属于独立模块<br>
|
||||
2. 本文件属于配置+文档一体化<br>
|
||||
|
||||
---
|
||||
|
||||
### 以下是配置区域:
|
||||
```json
|
||||
{
|
||||
"当前配置":"前缀码+当前时间+4位订单验证码+玩家名字",
|
||||
"前缀码":"CYZJ",
|
||||
"订单验证码长度":4,
|
||||
"是否固定订单号格式":false,
|
||||
"订单号格式":"[00000000]"
|
||||
}
|
||||
```
|
||||
---
|
||||
|
||||
### 以下是说明区域:
|
||||
|
||||
| 英文变量名 | 渲染内容 | 括弧支持 | 可否不写括号 | 中文变量名 |
|
||||
| :---: | :---: | :---: | :---: | :---: |
|
||||
| username | 玩家名字 | 中英小括号、中括号和大括号 | √ | 用户名/玩家名 |
|
||||
| front | 前缀码 | 中英小括号、中括号&大括号 | √ | 前缀码 |
|
||||
| timeis | 下单时间 | 中英小括号、中括号&大括号 | √ | 下单时间/当前时间 |
|
||||
| ordcode | 订单校验码 | 中英小括号、中括号&大括号 | √ | 验证码/下单码 |
|
||||
|
||||
|
||||
## 常见问题
|
||||
- 变量与变量之间如何连接?答: 使用数字加号或者减号连接。
|
||||
- 固定订单号格式是什么意思?答: 不按组合格式排列订单号,使用固定模板,"(前缀码)+(订单号)"格式
|
||||
- 固定格式订单如何防重?答: 自动进位,比如000001,000002。一个订单号对应一条付款记录
|
||||
- 固定订单格式支持哪些内容?答: 支持纯数字格式,其他都是回退六位兜底
|
||||
@@ -1,65 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>sdf1</groupId>
|
||||
<artifactId>Sdf1_login</artifactId>
|
||||
<version>1.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<java.version>27</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>
|
||||
<!-- ★ 与测试服/生产服运行时保持一致(Paper 26.x)。
|
||||
26.x 的命令分发改走 Brigadier:PlayerCommandPreprocessEvent 已
|
||||
@Deprecated 且不再是拦截入口,未命中的唯一拦截点是
|
||||
UnknownCommandEvent;聊天事件是 AsyncChatEvent 而非
|
||||
AsyncPlayerChatEvent。用 1.21.4 编译会因签名不匹配直接编不过。 -->
|
||||
<dependency>
|
||||
<groupId>io.papermc.paper</groupId>
|
||||
<artifactId>paper-api</artifactId>
|
||||
<version>26.2.build.129-stable</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.15.0</version>
|
||||
<configuration>
|
||||
<release>25</release>
|
||||
<encoding>UTF-8</encoding>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,95 @@
|
||||
# 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 用户指令,历史事故复盘见文件头注释)。
|
||||
|
||||
---
|
||||
@@ -0,0 +1,14 @@
|
||||
<?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;
|
||||
@@ -0,0 +1,274 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,50 @@
|
||||
[
|
||||
{
|
||||
"备注": {
|
||||
"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=泛域名"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,519 @@
|
||||
<?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 ''; }
|
||||
return m;
|
||||
});
|
||||
const left = images.filter(function(u, k) { return !used[k]; });
|
||||
if (left.length) out += (out ? '\n\n' : '') + left.map(function(u) { return ''; }).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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
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>');
|
||||
// 图片 
|
||||
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(/^(?:>[ \t]*)+(.*)$/gm, function(m, content) {
|
||||
let nestLevel = (m.match(/>/g) || []).length;
|
||||
let leftPad = Math.min(nestLevel * 6, 24);
|
||||
let text = content || ' ';
|
||||
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>
|
||||
@@ -0,0 +1,47 @@
|
||||
|
||||
```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格式
|
||||
@@ -1,197 +0,0 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Sound;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.scheduler.BukkitRunnable;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public class AFKManager {
|
||||
|
||||
// 玩家最后活动时间(空闲才开始计时)
|
||||
private final Map<UUID, Long> lastAction =
|
||||
new HashMap<>();
|
||||
// 每个玩家当前的警告阶段
|
||||
private final Map<UUID, Integer> lastStage =
|
||||
new HashMap<>();
|
||||
// 是否已进入挂机检测(false=还在活动,不计时)
|
||||
private final Map<UUID, Boolean> afkTracking =
|
||||
new HashMap<>();
|
||||
|
||||
private final ConfigManager config;
|
||||
private final JavaPlugin plugin;
|
||||
|
||||
public AFKManager(ConfigManager config,
|
||||
JavaPlugin plugin) {
|
||||
this.config = config;
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
public void startCheck() {
|
||||
new BukkitRunnable() {
|
||||
public void run() {
|
||||
tick();
|
||||
}
|
||||
}.runTaskTimer(plugin, 20L, 20L);
|
||||
}
|
||||
|
||||
private void tick() {
|
||||
if (!config.afkEnabled) return;
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
for (Player p : Bukkit.getOnlinePlayers()) {
|
||||
String name = p.getName();
|
||||
UUID uuid = p.getUniqueId();
|
||||
|
||||
if (!isLoggedIn(name)) continue;
|
||||
if (config.getAfkWhitelist().contains(name))
|
||||
continue;
|
||||
|
||||
// 确保有记录
|
||||
if (!lastAction.containsKey(uuid)) {
|
||||
lastAction.put(uuid, now);
|
||||
afkTracking.put(uuid, false);
|
||||
lastStage.put(uuid, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
long last = lastAction.get(uuid);
|
||||
long idle = (now - last) / 1000;
|
||||
int timeout = config.afkTimeout;
|
||||
|
||||
// 还在活动范围内:不进入挂机检测
|
||||
if (idle < 60) {
|
||||
afkTracking.put(uuid, false);
|
||||
lastStage.put(uuid, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 超过60秒无活动:开始挂机检测
|
||||
afkTracking.put(uuid, true);
|
||||
long remain = timeout - idle;
|
||||
|
||||
// 已超时:踢出
|
||||
if (remain <= 0) {
|
||||
p.kickPlayer("§c§l你已挂机 "
|
||||
+ fmtSec(idle) + ",被踢出");
|
||||
lastAction.remove(uuid);
|
||||
lastStage.remove(uuid);
|
||||
afkTracking.remove(uuid);
|
||||
continue;
|
||||
}
|
||||
|
||||
int stage = lastStage.getOrDefault(uuid, 0);
|
||||
|
||||
// 阶段3:最后10秒
|
||||
if (remain <= 10) {
|
||||
if (stage < 3) {
|
||||
lastStage.put(uuid, 3);
|
||||
}
|
||||
p.sendTitle(
|
||||
"§e§l挂机了 " + fmtSec(idle),
|
||||
"§c还剩 " + remain + " 秒踢出",
|
||||
0, 22, 3);
|
||||
playSound(p);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 阶段2:1分钟~10秒
|
||||
if (remain <= 60) {
|
||||
if (stage < 2) {
|
||||
lastStage.put(uuid, 2);
|
||||
p.sendMessage(
|
||||
"§e§l⚠ 挂机警告 ⚠ §f你已挂机 §c"
|
||||
+ fmtSec(idle)
|
||||
+ "§f,还剩 §c"
|
||||
+ fmtSec(remain)
|
||||
+ "§f踢出");
|
||||
}
|
||||
p.sendTitle(
|
||||
"§e§l挂机了 " + fmtSec(idle),
|
||||
"§c还剩 " + fmtSec(remain) + " 踢出",
|
||||
5, 25, 5);
|
||||
playSound(p);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 阶段1:3分钟提醒
|
||||
if (remain <= 180 && stage < 1) {
|
||||
lastStage.put(uuid, 1);
|
||||
p.sendMessage(
|
||||
"§e§l⚠ 挂机警告 ⚠ §f你已挂机 §c"
|
||||
+ fmtSec(idle)
|
||||
+ "§f,还剩 §c"
|
||||
+ fmtSec(remain)
|
||||
+ "§f踢出");
|
||||
p.sendTitle(
|
||||
"§e§l挂机了 " + fmtSec(idle),
|
||||
"§f还剩 " + fmtSec(remain) + " 踢出",
|
||||
10, 60, 20);
|
||||
for (int i = 0; i < 3; i++) {
|
||||
final int delay = i * 10;
|
||||
Bukkit.getScheduler().runTaskLater(
|
||||
plugin, () -> {
|
||||
if (p.isOnline()) playSound(p);
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
if (remain > 180) {
|
||||
lastStage.remove(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家有活动时调用(移动/交互/放置/挖掘等)
|
||||
* 重置计时,退出挂机检测
|
||||
*/
|
||||
public void recordAction(UUID uuid) {
|
||||
lastAction.put(uuid, System.currentTimeMillis());
|
||||
afkTracking.put(uuid, false);
|
||||
lastStage.put(uuid, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家下线时调用
|
||||
* 彻底停止计时,清理所有数据
|
||||
*/
|
||||
public void remove(UUID uuid) {
|
||||
lastAction.remove(uuid);
|
||||
lastStage.remove(uuid);
|
||||
afkTracking.remove(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取玩家当前是否在挂机检测中
|
||||
*/
|
||||
public boolean isAfkTracking(UUID uuid) {
|
||||
return afkTracking.getOrDefault(uuid, false);
|
||||
}
|
||||
|
||||
private boolean isLoggedIn(String name) {
|
||||
return ((Main) plugin).getLoggedIn()
|
||||
.contains(name);
|
||||
}
|
||||
|
||||
private void playSound(Player p) {
|
||||
p.playSound(p.getLocation(),
|
||||
Sound.BLOCK_NOTE_BLOCK_CHIME,
|
||||
1.0f, 0.79f);
|
||||
p.playSound(p.getLocation(),
|
||||
Sound.BLOCK_NOTE_BLOCK_BASS,
|
||||
1.0f, 0.79f);
|
||||
p.playSound(p.getLocation(),
|
||||
Sound.BLOCK_NOTE_BLOCK_PLING,
|
||||
1.0f, 0.79f);
|
||||
}
|
||||
|
||||
private String fmtSec(long sec) {
|
||||
if (sec < 60) return sec + "秒";
|
||||
long m = sec / 60;
|
||||
long s = sec % 60;
|
||||
return m + "分" + (s > 0 ? s + "秒" : "");
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package Sdf1_login;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class AccountRequestManager {
|
||||
|
||||
public enum ApprovalMode { AUTO, MANUAL, HYBRID }
|
||||
|
||||
public static class Request {
|
||||
public final int id;
|
||||
public final String requester;
|
||||
public final String target;
|
||||
public final String ip;
|
||||
public final long time;
|
||||
public String status;
|
||||
public String denyReason;
|
||||
|
||||
public Request(int id, String requester, String target, String ip) {
|
||||
this.id = id;
|
||||
this.requester = requester;
|
||||
this.target = target;
|
||||
this.ip = ip;
|
||||
this.time = System.currentTimeMillis();
|
||||
this.status = "PENDING";
|
||||
this.denyReason = "";
|
||||
}
|
||||
}
|
||||
|
||||
private final Main plugin;
|
||||
private final Map<Integer, Request> requests = new ConcurrentHashMap<>();
|
||||
private final Map<String, Integer> pendingByPlayer = new ConcurrentHashMap<>();
|
||||
private int nextId = 1;
|
||||
private ApprovalMode mode = ApprovalMode.MANUAL;
|
||||
private int autoDelayMinutes = 60;
|
||||
|
||||
public AccountRequestManager(Main plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
public ApprovalMode getMode() { return mode; }
|
||||
|
||||
public void setMode(String m) {
|
||||
try { this.mode = ApprovalMode.valueOf(m.toUpperCase()); }
|
||||
catch (Exception e) { this.mode = ApprovalMode.MANUAL; }
|
||||
}
|
||||
|
||||
public void setAutoDelayMinutes(int min) {
|
||||
this.autoDelayMinutes = min;
|
||||
}
|
||||
|
||||
public Request createRequest(String requester, String target, String ip) {
|
||||
int id = nextId++;
|
||||
Request req = new Request(id, requester, target, ip);
|
||||
requests.put(id, req);
|
||||
pendingByPlayer.put(requester, id);
|
||||
|
||||
if (mode == ApprovalMode.AUTO) {
|
||||
scheduleAutoApprove(id);
|
||||
} else if (mode == ApprovalMode.HYBRID) {
|
||||
String targetIp = (String) plugin.getDb().getField(target, "ip_address");
|
||||
if (ip != null && ip.equals(targetIp)) {
|
||||
scheduleAutoApprove(id);
|
||||
}
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
private void scheduleAutoApprove(int id) {
|
||||
long delayTicks = (long) autoDelayMinutes * 60L * 20L;
|
||||
Request req = requests.get(id);
|
||||
if (req == null) return;
|
||||
String requesterName = req.requester;
|
||||
String targetName = req.target;
|
||||
plugin.getLogger().info("[Sdf1_login] OA#" + id + " 将在 " + autoDelayMinutes + " 分钟后自动通过");
|
||||
|
||||
Bukkit.getScheduler().runTaskLater(plugin, () -> {
|
||||
Request r = requests.get(id);
|
||||
if (r != null && "PENDING".equals(r.status)) {
|
||||
approveRequest(id);
|
||||
plugin.getLogger().info("[Sdf1_login] OA#" + id + " 自动通过");
|
||||
Player requester = Bukkit.getPlayer(requesterName);
|
||||
if (requester != null && requester.isOnline()) {
|
||||
requester.sendMessage("§a[Sdf1_login] §f您的删除请求 #" + id + "(§e" + targetName + "§a)已自动通过!");
|
||||
}
|
||||
}
|
||||
}, delayTicks);
|
||||
}
|
||||
|
||||
public boolean approveRequest(int id) {
|
||||
Request req = requests.get(id);
|
||||
if (req == null || !"PENDING".equals(req.status)) return false;
|
||||
// ★ 精准匹配:TEST 与 test 是两个人,名字必须完全相等才删
|
||||
if (plugin.getDb().findExactUserName(req.target) == null) {
|
||||
String twin = plugin.getDb()
|
||||
.findSimilarUserNameIgnoreCase(req.target);
|
||||
System.out.println("[Sdf1_login] 删号申请 #"+ id
|
||||
+ " 未执行:账号 " + req.target
|
||||
+ (twin != null ? " 不存在,仅存在大小写不同的账号 " + twin : " 不存在")
|
||||
+ "(精准匹配,未误删)");
|
||||
return false;
|
||||
}
|
||||
int deleted = plugin.getDb().deleteUser(req.target);
|
||||
if (deleted <= 0) {
|
||||
return false;
|
||||
}
|
||||
req.status = "APPROVED";
|
||||
pendingByPlayer.remove(req.requester);
|
||||
// 清背包+传送出生点+踢出(★ getPlayerExact:避免误伤大小写不同的在线玩家)
|
||||
Player target = Bukkit.getPlayerExact(req.target);
|
||||
if (target != null && target.isOnline()) {
|
||||
target.getInventory().clear();
|
||||
target.getInventory().setArmorContents(new org.bukkit.inventory.ItemStack[4]);
|
||||
target.getInventory().setExtraContents(new org.bukkit.inventory.ItemStack[1]);
|
||||
target.teleport(target.getWorld().getSpawnLocation());
|
||||
target.kickPlayer("§c§l你的账号已被管理员删除,请重新注册");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
public boolean denyRequest(int id, String reason) {
|
||||
Request req = requests.get(id);
|
||||
if (req == null || !"PENDING".equals(req.status)) return false;
|
||||
req.status = "DENIED";
|
||||
req.denyReason = reason;
|
||||
pendingByPlayer.remove(req.requester);
|
||||
return true;
|
||||
}
|
||||
|
||||
public List<Request> getPendingRequests() {
|
||||
List<Request> list = new ArrayList<>();
|
||||
for (Request r : requests.values()) {
|
||||
if ("PENDING".equals(r.status)) list.add(r);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public Request getRequest(int id) {
|
||||
return requests.get(id);
|
||||
}
|
||||
|
||||
public boolean hasPending(String player) {
|
||||
return pendingByPlayer.containsKey(player);
|
||||
}
|
||||
|
||||
public void cancelPending(String player) {
|
||||
Integer id = pendingByPlayer.remove(player);
|
||||
if (id != null) {
|
||||
Request r = requests.get(id);
|
||||
if (r != null) r.status = "CANCELLED";
|
||||
}
|
||||
}
|
||||
}
|
||||