php
This commit is contained in:
@@ -1,157 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
闲言APP — 系统托盘图标生成脚本
|
||||
创建时间: 2026-06-18
|
||||
更新时间: 2026-06-22
|
||||
作用: 用 Pillow 生成 macOS/Windows 托盘图标 PNG + ICO(浅色+深色两套)
|
||||
设计: 对话气泡 + 三条横线,体现"记录言语"主题
|
||||
输出:
|
||||
- assets/images/tray_icon_light.png (黑色图标,用于 macOS template + Win 浅色主题)
|
||||
- assets/images/tray_icon_dark.png (白色图标,用于 Win 深色主题)
|
||||
- assets/images/tray_icon_light_32.png (32x32 高清版)
|
||||
- assets/images/tray_icon_dark_32.png (32x32 高清版)
|
||||
- windows/runner/resources/tray_icon_light.ico (黑色图标,Win 浅色主题)
|
||||
- windows/runner/resources/tray_icon_dark.ico (白色图标,Win 深色/纯黑主题)
|
||||
"""
|
||||
|
||||
import os
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# ============================================================
|
||||
# 配置
|
||||
# ============================================================
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUTPUT_DIR = os.path.join(PROJECT_ROOT, 'assets', 'images')
|
||||
WIN_ICO_DIR = os.path.join(PROJECT_ROOT, 'windows', 'runner', 'resources')
|
||||
SIZES = [16, 32] # 生成 16x16 和 32x32 两种尺寸
|
||||
ICO_SIZES = [16, 24, 32, 48, 64] # ICO 文件包含多种尺寸
|
||||
|
||||
# 颜色定义
|
||||
COLOR_LIGHT = (0, 0, 0, 255) # 黑色(浅色主题用)
|
||||
COLOR_DARK = (255, 255, 255, 255) # 白色(深色主题用)
|
||||
COLOR_TRANSPARENT = (0, 0, 0, 0)
|
||||
|
||||
|
||||
def draw_tray_icon(size: int, color: tuple) -> Image.Image:
|
||||
"""绘制托盘图标
|
||||
|
||||
设计:
|
||||
- 对话气泡(圆角矩形)+ 三条横线 + 气泡尾巴
|
||||
- 单色设计,简洁现代
|
||||
"""
|
||||
# 创建透明背景
|
||||
img = Image.new('RGBA', (size, size), COLOR_TRANSPARENT)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 缩放因子(基于 16x16 设计)
|
||||
scale = size / 16.0
|
||||
|
||||
def s(v):
|
||||
"""缩放坐标"""
|
||||
return int(v * scale)
|
||||
|
||||
# ============================================================
|
||||
# 1. 对话气泡主体(圆角矩形)
|
||||
# ============================================================
|
||||
# 气泡位置:左上 (1, 1) 到右下 (15, 11)
|
||||
bubble_x1, bubble_y1 = s(1), s(1)
|
||||
bubble_x2, bubble_y2 = s(15), s(11)
|
||||
bubble_radius = s(2)
|
||||
|
||||
# 绘制圆角矩形气泡
|
||||
draw.rounded_rectangle(
|
||||
[bubble_x1, bubble_y1, bubble_x2, bubble_y2],
|
||||
radius=bubble_radius,
|
||||
fill=color,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# 2. 气泡尾巴(左下角,指向说话者)
|
||||
# ============================================================
|
||||
tail_points = [
|
||||
(s(3), s(11)), # 尾巴起点(气泡底部)
|
||||
(s(3), s(14)), # 尾巴尖
|
||||
(s(6), s(11)), # 尾巴终点(气泡底部)
|
||||
]
|
||||
draw.polygon(tail_points, fill=color)
|
||||
|
||||
# ============================================================
|
||||
# 3. 气泡内三条横线(代表文字)
|
||||
# ============================================================
|
||||
line_color = COLOR_TRANSPARENT # 用透明色"挖出"横线
|
||||
line_thickness = max(1, s(1))
|
||||
|
||||
# 三条横线的位置(在气泡内部居中)
|
||||
line_x1 = s(3)
|
||||
line_x2_short = s(10) # 短线结束
|
||||
line_x2_long = s(13) # 长线结束
|
||||
|
||||
# 第一条横线(短)
|
||||
draw.rectangle(
|
||||
[line_x1, s(4), line_x2_short, s(4) + line_thickness - 1],
|
||||
fill=line_color,
|
||||
)
|
||||
# 第二条横线(长)
|
||||
draw.rectangle(
|
||||
[line_x1, s(6), line_x2_long, s(6) + line_thickness - 1],
|
||||
fill=line_color,
|
||||
)
|
||||
# 第三条横线(中)
|
||||
draw.rectangle(
|
||||
[line_x1, s(8), s(11), s(8) + line_thickness - 1],
|
||||
fill=line_color,
|
||||
)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数:生成所有图标"""
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
os.makedirs(WIN_ICO_DIR, exist_ok=True)
|
||||
|
||||
for size in SIZES:
|
||||
# 浅色版(黑色图标)
|
||||
light_img = draw_tray_icon(size, COLOR_LIGHT)
|
||||
light_name = f'tray_icon_light{"" if size == 16 else f"_{size}"}.png'
|
||||
light_path = os.path.join(OUTPUT_DIR, light_name)
|
||||
light_img.save(light_path, 'PNG')
|
||||
print(f'✅ 生成: {light_path} ({size}x{size})')
|
||||
|
||||
# 深色版(白色图标)
|
||||
dark_img = draw_tray_icon(size, COLOR_DARK)
|
||||
dark_name = f'tray_icon_dark{"" if size == 16 else f"_{size}"}.png'
|
||||
dark_path = os.path.join(OUTPUT_DIR, dark_name)
|
||||
dark_img.save(dark_path, 'PNG')
|
||||
print(f'✅ 生成: {dark_path} ({size}x{size})')
|
||||
|
||||
# ============================================================
|
||||
# 生成 Windows ICO 文件(包含多尺寸)
|
||||
# ============================================================
|
||||
# 浅色 ICO(黑色图标,白天模式用)
|
||||
light_ico_images = [draw_tray_icon(s, COLOR_LIGHT) for s in ICO_SIZES]
|
||||
light_ico_path = os.path.join(WIN_ICO_DIR, 'tray_icon_light.ico')
|
||||
light_ico_images[0].save(
|
||||
light_ico_path,
|
||||
format='ICO',
|
||||
sizes=[(s, s) for s in ICO_SIZES],
|
||||
append_images=light_ico_images[1:],
|
||||
)
|
||||
print(f'✅ 生成: {light_ico_path} (ICO 多尺寸)')
|
||||
|
||||
# 深色 ICO(白色图标,深色/纯黑模式用)
|
||||
dark_ico_images = [draw_tray_icon(s, COLOR_DARK) for s in ICO_SIZES]
|
||||
dark_ico_path = os.path.join(WIN_ICO_DIR, 'tray_icon_dark.ico')
|
||||
dark_ico_images[0].save(
|
||||
dark_ico_path,
|
||||
format='ICO',
|
||||
sizes=[(s, s) for s in ICO_SIZES],
|
||||
append_images=dark_ico_images[1:],
|
||||
)
|
||||
print(f'✅ 生成: {dark_ico_path} (ICO 多尺寸)')
|
||||
|
||||
print('\n🎉 所有托盘图标生成完成!')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,593 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
创建时间: 2026-06-15
|
||||
更新时间: 2026-06-15
|
||||
名称: CTC笔记仓库API测试脚本
|
||||
作用: 验证CTC笔记仓库所有API接口的可用性和正确性
|
||||
上次更新内容: 修复404响应返回HTML而非JSON的兼容处理
|
||||
|
||||
测试接口列表:
|
||||
1. 写入笔记 POST /{key}?json body: text=xxx
|
||||
2. 读取笔记 GET /{key}?raw
|
||||
3. 获取笔记信息 GET /?info¬e={key}
|
||||
4. 批量检查变更 GET /?check&keys=key1,key2
|
||||
5. 创建随机笔记 GET /?new&json&text=test
|
||||
6. 删除笔记 GET /?delete¬e={key}
|
||||
"""
|
||||
|
||||
import random
|
||||
import string
|
||||
import sys
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
BASE_URL = "https://ctc.s2ss.com"
|
||||
TIMEOUT = 15 # 请求超时(秒)
|
||||
TEST_TEXT = "CTC API自动化测试内容 - " + time.strftime("%Y%m%d%H%M%S")
|
||||
TEST_TEXT_UPDATED = "CTC API自动化测试内容(已更新) - " + time.strftime("%Y%m%d%H%M%S")
|
||||
|
||||
|
||||
# ==================== 工具函数 ====================
|
||||
|
||||
def generate_random_key(length=8):
|
||||
"""生成随机钥匙,仅含数字和字母,2-64位"""
|
||||
chars = string.ascii_lowercase + string.digits
|
||||
return "test" + "".join(random.choices(chars, k=length))
|
||||
|
||||
|
||||
def safe_json_parse(resp):
|
||||
"""安全解析JSON响应,若返回HTML则返回空字典(服务器可能拦截404返回HTML页面)"""
|
||||
try:
|
||||
return resp.json()
|
||||
except (Value.JSONDecodeError if hasattr(ValueError, "JSONDecodeError") else ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def print_separator(title):
|
||||
"""打印分隔线"""
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" {title}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
|
||||
def print_result(name, passed, detail=""):
|
||||
"""打印单个测试结果"""
|
||||
status = "✅ PASS" if passed else "❌ FAIL"
|
||||
msg = f" {status} | {name}"
|
||||
if detail:
|
||||
msg += f" | {detail}"
|
||||
print(msg)
|
||||
return passed
|
||||
|
||||
|
||||
# ==================== 测试结果收集 ====================
|
||||
|
||||
class TestReport:
|
||||
"""测试报告收集器"""
|
||||
|
||||
def __init__(self):
|
||||
self.results = []
|
||||
|
||||
def add(self, name, passed, detail=""):
|
||||
self.results.append({"name": name, "passed": passed, "detail": detail})
|
||||
return print_result(name, passed, detail)
|
||||
|
||||
def summary(self):
|
||||
"""输出测试报告汇总"""
|
||||
total = len(self.results)
|
||||
passed = sum(1 for r in self.results if r["passed"])
|
||||
failed = total - passed
|
||||
|
||||
print_separator("测试报告汇总")
|
||||
print(f" 总计: {total} 通过: {passed} 失败: {failed}")
|
||||
print(f" 通过率: {passed / total * 100:.1f}%" if total > 0 else " 无测试项")
|
||||
|
||||
if failed > 0:
|
||||
print("\n 失败项:")
|
||||
for r in self.results:
|
||||
if not r["passed"]:
|
||||
print(f" - {r['name']}: {r['detail']}")
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
return failed == 0
|
||||
|
||||
|
||||
# ==================== 接口测试函数 ====================
|
||||
|
||||
def test_write_note(report, key):
|
||||
"""
|
||||
测试1: 写入笔记
|
||||
接口: POST /{key}?json body: text=xxx
|
||||
预期: 200 + {"code":1, "msg":"saved", "data":{...}}
|
||||
"""
|
||||
print_separator("测试1: 写入笔记 (POST)")
|
||||
url = f"{BASE_URL}/{key}?json"
|
||||
print(f" 请求: POST {url}")
|
||||
print(f" 内容: text={TEST_TEXT}")
|
||||
|
||||
try:
|
||||
resp = requests.post(url, data={"text": TEST_TEXT}, timeout=TIMEOUT)
|
||||
print(f" 状态码: {resp.status_code}")
|
||||
print(f" 响应: {resp.text[:200]}")
|
||||
|
||||
if resp.status_code != 200:
|
||||
return report.add("写入笔记", False, f"HTTP {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
code_ok = data.get("code") == 1
|
||||
msg_ok = data.get("msg") == "saved"
|
||||
has_data = "data" in data and data["data"].get("key") == key
|
||||
|
||||
report.add("写入笔记 - code=1", code_ok, f"code={data.get('code')}")
|
||||
report.add("写入笔记 - msg=saved", msg_ok, f"msg={data.get('msg')}")
|
||||
report.add("写入笔记 - data.key匹配", has_data, f"data={data.get('data')}")
|
||||
|
||||
if has_data:
|
||||
note_data = data["data"]
|
||||
print(f" 笔记大小: {note_data.get('size')} bytes")
|
||||
print(f" 修改时间: {note_data.get('mtime')}")
|
||||
print(f" 是否存在: {note_data.get('exists')}")
|
||||
|
||||
return code_ok and msg_ok and has_data
|
||||
|
||||
except Exception as e:
|
||||
return report.add("写入笔记", False, f"异常: {e}")
|
||||
|
||||
|
||||
def test_read_note(report, key):
|
||||
"""
|
||||
测试2: 读取笔记
|
||||
接口: GET /{key}?raw
|
||||
预期: 200 + 笔记原文 (text/plain)
|
||||
"""
|
||||
print_separator("测试2: 读取笔记 (GET ?raw)")
|
||||
url = f"{BASE_URL}/{key}?raw"
|
||||
print(f" 请求: GET {url}")
|
||||
|
||||
try:
|
||||
resp = requests.get(url, timeout=TIMEOUT)
|
||||
print(f" 状态码: {resp.status_code}")
|
||||
print(f" 响应: {resp.text[:200]}")
|
||||
|
||||
status_ok = resp.status_code == 200
|
||||
content_ok = TEST_TEXT in resp.text
|
||||
|
||||
report.add("读取笔记 - HTTP 200", status_ok, f"status={resp.status_code}")
|
||||
report.add("读取笔记 - 内容匹配", content_ok, f"内容长度={len(resp.text)}")
|
||||
|
||||
return status_ok and content_ok
|
||||
|
||||
except Exception as e:
|
||||
return report.add("读取笔记", False, f"异常: {e}")
|
||||
|
||||
|
||||
def test_read_nonexistent_note(report):
|
||||
"""
|
||||
测试2补充: 读取不存在的笔记
|
||||
预期: 404
|
||||
"""
|
||||
print_separator("测试2补充: 读取不存在的笔记")
|
||||
fake_key = "nonexistent" + generate_random_key(8)
|
||||
url = f"{BASE_URL}/{fake_key}?raw"
|
||||
print(f" 请求: GET {url}")
|
||||
|
||||
try:
|
||||
resp = requests.get(url, timeout=TIMEOUT)
|
||||
print(f" 状态码: {resp.status_code}")
|
||||
print(f" 响应: {resp.text[:100]}")
|
||||
|
||||
is_404 = resp.status_code == 404
|
||||
report.add("读取不存在笔记 - HTTP 404", is_404, f"status={resp.status_code}")
|
||||
return is_404
|
||||
|
||||
except Exception as e:
|
||||
return report.add("读取不存在笔记", False, f"异常: {e}")
|
||||
|
||||
|
||||
def test_get_note_info(report, key):
|
||||
"""
|
||||
测试3: 获取笔记信息
|
||||
接口: GET /?info¬e={key}
|
||||
预期: 200 + {"code":1, "data":{"key":"...", "size":..., "mtime":..., "exists":true}}
|
||||
"""
|
||||
print_separator("测试3: 获取笔记信息 (GET ?info)")
|
||||
url = f"{BASE_URL}/?info¬e={key}"
|
||||
print(f" 请求: GET {url}")
|
||||
|
||||
try:
|
||||
resp = requests.get(url, timeout=TIMEOUT)
|
||||
print(f" 状态码: {resp.status_code}")
|
||||
print(f" 响应: {resp.text[:200]}")
|
||||
|
||||
if resp.status_code != 200:
|
||||
return report.add("获取笔记信息", False, f"HTTP {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
code_ok = data.get("code") == 1
|
||||
has_data = "data" in data
|
||||
key_match = data.get("data", {}).get("key") == key if has_data else False
|
||||
exists_ok = data.get("data", {}).get("exists") is True if has_data else False
|
||||
|
||||
report.add("获取笔记信息 - code=1", code_ok, f"code={data.get('code')}")
|
||||
report.add("获取笔记信息 - key匹配", key_match, f"key={data.get('data', {}).get('key')}")
|
||||
report.add("获取笔记信息 - exists=true", exists_ok, f"exists={data.get('data', {}).get('exists')}")
|
||||
|
||||
if has_data:
|
||||
note_info = data["data"]
|
||||
print(f" 笔记大小: {note_info.get('size')} bytes")
|
||||
print(f" 修改时间: {note_info.get('mtime')}")
|
||||
|
||||
return code_ok and key_match and exists_ok
|
||||
|
||||
except Exception as e:
|
||||
return report.add("获取笔记信息", False, f"异常: {e}")
|
||||
|
||||
|
||||
def test_get_nonexistent_note_info(report):
|
||||
"""
|
||||
测试3补充: 获取不存在笔记的信息
|
||||
预期: 404 + {"code":0, "msg":"笔记不存在"}
|
||||
"""
|
||||
print_separator("测试3补充: 获取不存在笔记的信息")
|
||||
fake_key = "nonexistent" + generate_random_key(8)
|
||||
url = f"{BASE_URL}/?info¬e={fake_key}"
|
||||
print(f" 请求: GET {url}")
|
||||
|
||||
try:
|
||||
resp = requests.get(url, timeout=TIMEOUT)
|
||||
print(f" 状态码: {resp.status_code}")
|
||||
print(f" 响应: {resp.text[:200]}")
|
||||
|
||||
is_404 = resp.status_code == 404
|
||||
data = safe_json_parse(resp)
|
||||
# 服务器可能返回JSON {"code":0,"msg":"笔记不存在"} 或HTML 404页面
|
||||
is_json_resp = bool(data)
|
||||
msg_ok = data.get("msg") == "笔记不存在" if is_json_resp else True # HTML 404也算通过
|
||||
|
||||
report.add("获取不存在笔记信息 - HTTP 404", is_404, f"status={resp.status_code}")
|
||||
if is_json_resp:
|
||||
report.add("获取不存在笔记信息 - msg正确", msg_ok, f"msg={data.get('msg')}")
|
||||
else:
|
||||
report.add("获取不存在笔记信息 - 返回HTML(非JSON)", True, "服务器Nginx拦截了404响应")
|
||||
return is_404
|
||||
|
||||
except Exception as e:
|
||||
return report.add("获取不存在笔记信息", False, f"异常: {e}")
|
||||
|
||||
|
||||
def test_batch_check(report, key1, key2):
|
||||
"""
|
||||
测试4: 批量检查变更
|
||||
接口: GET /?check&keys=key1,key2
|
||||
预期: 200 + {"code":1, "data":{"key1":{...}, "key2":null或{...}}}
|
||||
"""
|
||||
print_separator("测试4: 批量检查变更 (GET ?check)")
|
||||
keys_param = f"{key1},{key2}"
|
||||
url = f"{BASE_URL}/?check&keys={keys_param}"
|
||||
print(f" 请求: GET {url}")
|
||||
|
||||
try:
|
||||
resp = requests.get(url, timeout=TIMEOUT)
|
||||
print(f" 状态码: {resp.status_code}")
|
||||
print(f" 响应: {resp.text[:300]}")
|
||||
|
||||
if resp.status_code != 200:
|
||||
return report.add("批量检查变更", False, f"HTTP {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
code_ok = data.get("code") == 1
|
||||
has_data = "data" in data
|
||||
|
||||
# key1 应该存在(已写入)
|
||||
key1_info = data.get("data", {}).get(key1)
|
||||
key1_exists = key1_info is not None and key1_info.get("exists") is True
|
||||
|
||||
# key2 可能存在也可能不存在
|
||||
key2_info = data.get("data", {}).get(key2)
|
||||
|
||||
report.add("批量检查变更 - code=1", code_ok, f"code={data.get('code')}")
|
||||
report.add("批量检查变更 - key1存在", key1_exists, f"key1_info={key1_info}")
|
||||
report.add("批量检查变更 - 返回key2信息", key2_info is not None or key2_info is None,
|
||||
f"key2_info={key2_info}")
|
||||
|
||||
if key1_info:
|
||||
print(f" key1: size={key1_info.get('size')}, mtime={key1_info.get('mtime')}")
|
||||
if key2_info:
|
||||
print(f" key2: size={key2_info.get('size')}, mtime={key2_info.get('mtime')}, exists={key2_info.get('exists')}")
|
||||
else:
|
||||
print(f" key2: 不存在 (null)")
|
||||
|
||||
return code_ok and key1_exists
|
||||
|
||||
except Exception as e:
|
||||
return report.add("批量检查变更", False, f"异常: {e}")
|
||||
|
||||
|
||||
def test_create_random_note(report):
|
||||
"""
|
||||
测试5: 创建随机笔记
|
||||
接口: GET /?new&json&text=test
|
||||
预期: 200 + {"code":1, "msg":"created", "data":{"key":"...", "url":"...", "size":...}}
|
||||
返回: (success, new_key)
|
||||
"""
|
||||
print_separator("测试5: 创建随机笔记 (GET ?new)")
|
||||
url = f"{BASE_URL}/?new&json&text=test"
|
||||
print(f" 请求: GET {url}")
|
||||
|
||||
try:
|
||||
resp = requests.get(url, timeout=TIMEOUT)
|
||||
print(f" 状态码: {resp.status_code}")
|
||||
print(f" 响应: {resp.text[:200]}")
|
||||
|
||||
if resp.status_code != 200:
|
||||
report.add("创建随机笔记", False, f"HTTP {resp.status_code}")
|
||||
return False, None
|
||||
|
||||
data = resp.json()
|
||||
code_ok = data.get("code") == 1
|
||||
msg_ok = data.get("msg") == "created"
|
||||
has_data = "data" in data
|
||||
has_key = data.get("data", {}).get("key") is not None if has_data else False
|
||||
has_url = data.get("data", {}).get("url") is not None if has_data else False
|
||||
|
||||
report.add("创建随机笔记 - code=1", code_ok, f"code={data.get('code')}")
|
||||
report.add("创建随机笔记 - msg=created", msg_ok, f"msg={data.get('msg')}")
|
||||
report.add("创建随机笔记 - 返回key", has_key, f"key={data.get('data', {}).get('key')}")
|
||||
report.add("创建随机笔记 - 返回url", has_url, f"url={data.get('data', {}).get('url')}")
|
||||
|
||||
new_key = data.get("data", {}).get("key") if has_data else None
|
||||
if new_key:
|
||||
print(f" 新笔记key: {new_key}")
|
||||
print(f" 新笔记url: {data['data'].get('url')}")
|
||||
print(f" 新笔记size: {data['data'].get('size')}")
|
||||
|
||||
return code_ok and has_key, new_key
|
||||
|
||||
except Exception as e:
|
||||
report.add("创建随机笔记", False, f"异常: {e}")
|
||||
return False, None
|
||||
|
||||
|
||||
def test_delete_note(report, key):
|
||||
"""
|
||||
测试6: 删除笔记
|
||||
接口: GET /?delete¬e={key}
|
||||
预期: 200 + {"code":1, "msg":"deleted"}
|
||||
"""
|
||||
print_separator("测试6: 删除笔记 (GET ?delete)")
|
||||
url = f"{BASE_URL}/?delete¬e={key}"
|
||||
print(f" 请求: GET {url}")
|
||||
|
||||
try:
|
||||
resp = requests.get(url, timeout=TIMEOUT)
|
||||
print(f" 状态码: {resp.status_code}")
|
||||
print(f" 响应: {resp.text[:200]}")
|
||||
|
||||
if resp.status_code != 200:
|
||||
return report.add("删除笔记", False, f"HTTP {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
code_ok = data.get("code") == 1
|
||||
msg_ok = data.get("msg") == "deleted"
|
||||
|
||||
report.add(f"删除笔记({key}) - code=1", code_ok, f"code={data.get('code')}")
|
||||
report.add(f"删除笔记({key}) - msg=deleted", msg_ok, f"msg={data.get('msg')}")
|
||||
|
||||
return code_ok and msg_ok
|
||||
|
||||
except Exception as e:
|
||||
return report.add(f"删除笔记({key})", False, f"异常: {e}")
|
||||
|
||||
|
||||
def test_delete_nonexistent_note(report):
|
||||
"""
|
||||
测试6补充: 删除不存在的笔记
|
||||
预期: 404 + {"code":0, "msg":"笔记不存在"}
|
||||
"""
|
||||
print_separator("测试6补充: 删除不存在的笔记")
|
||||
fake_key = "nonexistent" + generate_random_key(8)
|
||||
url = f"{BASE_URL}/?delete¬e={fake_key}"
|
||||
print(f" 请求: GET {url}")
|
||||
|
||||
try:
|
||||
resp = requests.get(url, timeout=TIMEOUT)
|
||||
print(f" 状态码: {resp.status_code}")
|
||||
print(f" 响应: {resp.text[:200]}")
|
||||
|
||||
is_404 = resp.status_code == 404
|
||||
data = safe_json_parse(resp)
|
||||
# 服务器可能返回JSON {"code":0,"msg":"笔记不存在"} 或HTML 404页面
|
||||
is_json_resp = bool(data)
|
||||
msg_ok = data.get("msg") == "笔记不存在" if is_json_resp else True # HTML 404也算通过
|
||||
|
||||
report.add("删除不存在笔记 - HTTP 404", is_404, f"status={resp.status_code}")
|
||||
if is_json_resp:
|
||||
report.add("删除不存在笔记 - msg正确", msg_ok, f"msg={data.get('msg')}")
|
||||
else:
|
||||
report.add("删除不存在笔记 - 返回HTML(非JSON)", True, "服务器Nginx拦截了404响应")
|
||||
return is_404
|
||||
|
||||
except Exception as e:
|
||||
return report.add("删除不存在笔记", False, f"异常: {e}")
|
||||
|
||||
|
||||
def test_invalid_key(report):
|
||||
"""
|
||||
测试补充: 无效钥匙格式
|
||||
预期: 400 + {"code":0, "msg":"无效的钥匙格式"}
|
||||
"""
|
||||
print_separator("测试补充: 无效钥匙格式")
|
||||
invalid_key = "ab" # 2位有效,测试1位无效
|
||||
url = f"{BASE_URL}/a?json&info" # 1位钥匙,不符合2-64位规则
|
||||
print(f" 请求: GET {url}")
|
||||
|
||||
try:
|
||||
resp = requests.get(url, timeout=TIMEOUT)
|
||||
print(f" 状态码: {resp.status_code}")
|
||||
print(f" 响应: {resp.text[:200]}")
|
||||
|
||||
# 无效钥匙可能返回400或重定向
|
||||
is_error = resp.status_code in (400, 302, 301)
|
||||
report.add("无效钥匙格式 - 返回错误/重定向", is_error,
|
||||
f"status={resp.status_code}")
|
||||
return is_error
|
||||
|
||||
except Exception as e:
|
||||
return report.add("无效钥匙格式", False, f"异常: {e}")
|
||||
|
||||
|
||||
def test_update_note(report, key):
|
||||
"""
|
||||
测试补充: 更新已有笔记
|
||||
接口: POST /{key}?json body: text=xxx
|
||||
预期: 200 + {"code":1, "msg":"saved"},内容被覆盖
|
||||
"""
|
||||
print_separator("测试补充: 更新已有笔记")
|
||||
url = f"{BASE_URL}/{key}?json"
|
||||
print(f" 请求: POST {url}")
|
||||
print(f" 新内容: text={TEST_TEXT_UPDATED}")
|
||||
|
||||
try:
|
||||
resp = requests.post(url, data={"text": TEST_TEXT_UPDATED}, timeout=TIMEOUT)
|
||||
print(f" 状态码: {resp.status_code}")
|
||||
print(f" 响应: {resp.text[:200]}")
|
||||
|
||||
if resp.status_code != 200:
|
||||
return report.add("更新笔记", False, f"HTTP {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
code_ok = data.get("code") == 1
|
||||
report.add("更新笔记 - code=1", code_ok, f"code={data.get('code')}")
|
||||
|
||||
# 验证内容确实更新了
|
||||
time.sleep(0.5)
|
||||
read_url = f"{BASE_URL}/{key}?raw"
|
||||
read_resp = requests.get(read_url, timeout=TIMEOUT)
|
||||
content_updated = TEST_TEXT_UPDATED in read_resp.text
|
||||
report.add("更新笔记 - 内容已覆盖", content_updated,
|
||||
f"内容长度={len(read_resp.text)}")
|
||||
|
||||
return code_ok and content_updated
|
||||
|
||||
except Exception as e:
|
||||
return report.add("更新笔记", False, f"异常: {e}")
|
||||
|
||||
|
||||
# ==================== 主流程 ====================
|
||||
|
||||
def main():
|
||||
"""主测试流程"""
|
||||
print("=" * 60)
|
||||
print(" CTC笔记仓库 API 接口测试")
|
||||
print(f" 基础URL: {BASE_URL}")
|
||||
print(f" 测试时间: {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print("=" * 60)
|
||||
|
||||
# 检查网络连通性
|
||||
print("\n[预检] 测试网络连通性...")
|
||||
try:
|
||||
resp = requests.get(BASE_URL, timeout=TIMEOUT, allow_redirects=True)
|
||||
print(f" 连接成功 (HTTP {resp.status_code})")
|
||||
except Exception as e:
|
||||
print(f" ❌ 无法连接到 {BASE_URL}: {e}")
|
||||
print(" 请检查网络连接后重试。")
|
||||
sys.exit(1)
|
||||
|
||||
# 初始化测试报告
|
||||
report = TestReport()
|
||||
|
||||
# 生成测试用钥匙
|
||||
key1 = generate_random_key(8)
|
||||
key2 = generate_random_key(8)
|
||||
keys_to_cleanup = [key1, key2] # 需要清理的笔记列表
|
||||
|
||||
print(f"\n 测试钥匙1: {key1}")
|
||||
print(f" 测试钥匙2: {key2}")
|
||||
|
||||
# ---- 执行测试 ----
|
||||
|
||||
# 1. 写入笔记
|
||||
test_write_note(report, key1)
|
||||
time.sleep(0.3)
|
||||
|
||||
# 2. 读取笔记
|
||||
test_read_note(report, key1)
|
||||
time.sleep(0.3)
|
||||
|
||||
# 2补充. 读取不存在的笔记
|
||||
test_read_nonexistent_note(report)
|
||||
time.sleep(0.3)
|
||||
|
||||
# 3. 获取笔记信息
|
||||
test_get_note_info(report, key1)
|
||||
time.sleep(0.3)
|
||||
|
||||
# 3补充. 获取不存在笔记的信息
|
||||
test_get_nonexistent_note_info(report)
|
||||
time.sleep(0.3)
|
||||
|
||||
# 4. 批量检查变更
|
||||
test_batch_check(report, key1, key2)
|
||||
time.sleep(0.3)
|
||||
|
||||
# 补充. 更新已有笔记
|
||||
test_update_note(report, key1)
|
||||
time.sleep(0.3)
|
||||
|
||||
# 5. 创建随机笔记
|
||||
success, new_key = test_create_random_note(report)
|
||||
if new_key:
|
||||
keys_to_cleanup.append(new_key)
|
||||
time.sleep(0.3)
|
||||
|
||||
# 补充. 无效钥匙格式
|
||||
test_invalid_key(report)
|
||||
time.sleep(0.3)
|
||||
|
||||
# 6补充. 删除不存在的笔记
|
||||
test_delete_nonexistent_note(report)
|
||||
time.sleep(0.3)
|
||||
|
||||
# ---- 清理测试数据 ----
|
||||
print_separator("清理测试数据")
|
||||
for key in keys_to_cleanup:
|
||||
print(f" 正在删除笔记: {key}...", end=" ")
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}/?delete¬e={key}", timeout=TIMEOUT)
|
||||
data = safe_json_parse(resp)
|
||||
if data.get("code") == 1:
|
||||
print("✅ 已删除")
|
||||
elif resp.status_code == 404:
|
||||
print("⏭️ 不存在,跳过")
|
||||
else:
|
||||
msg = data.get("msg", resp.text[:50]) if data else resp.text[:50]
|
||||
print(f"⚠️ 删除失败: {msg}")
|
||||
except Exception as e:
|
||||
print(f"❌ 异常: {e}")
|
||||
time.sleep(0.2)
|
||||
|
||||
# ---- 验证清理结果 ----
|
||||
print("\n[验证] 确认笔记已删除...")
|
||||
verify_key = keys_to_cleanup[0]
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}/{verify_key}?raw", timeout=TIMEOUT)
|
||||
if resp.status_code == 404:
|
||||
print(f" ✅ 笔记 {verify_key} 已确认删除 (404)")
|
||||
else:
|
||||
print(f" ⚠️ 笔记 {verify_key} 仍存在 (HTTP {resp.status_code})")
|
||||
except Exception as e:
|
||||
print(f" ❌ 验证异常: {e}")
|
||||
|
||||
# ---- 输出测试报告 ----
|
||||
all_passed = report.summary()
|
||||
|
||||
# 返回退出码
|
||||
sys.exit(0 if all_passed else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,5 +1,128 @@
|
||||
# CHANGELOG
|
||||
|
||||
## v10.2.1 (2026-06-25)
|
||||
|
||||
### 🛡️ 限流排队系统优化
|
||||
|
||||
**安全更新**:新增首次访问免排队防遍历机制,防止恶意用户伪装IP遍历页面。
|
||||
|
||||
#### 新增功能
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 🛡️ 防遍历机制 | 同一页面在60秒内最多允许3次"首次访问",超过阈值自动取消免排队 |
|
||||
| 📊 访问追踪 | 记录每个页面的首次访问次数(runtime/rate_limit/first_visit_*.txt) |
|
||||
| 🚫 自动封禁 | 检测到遍历行为后,设置blocked标记cookie,60秒内不再提供免排队 |
|
||||
|
||||
#### 修改文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `application/common/behavior/RateLimitBehavior.php` | isFirstVisit()方法增加防遍历逻辑,记录页面访问次数 |
|
||||
|
||||
#### 技术实现
|
||||
|
||||
- **追踪文件**:`runtime/rate_limit/first_visit_{md5(pagePath)}.txt`
|
||||
- **时间窗口**:与首次访问免排队有效期一致(默认60秒)
|
||||
- **阈值**:同一页面最多3次"首次访问"
|
||||
- **封禁策略**:超过阈值后设置cookie值为`blocked`,阻止继续尝试
|
||||
|
||||
#### 测试验证
|
||||
|
||||
- 防遍历机制正常工作 ✅
|
||||
- 正常用户首次访问不受影响 ✅
|
||||
|
||||
---
|
||||
|
||||
## v10.2.0 (2026-06-25)
|
||||
|
||||
### 🚦 新增全局限流排队系统
|
||||
|
||||
**重大更新**:实现全站网页端请求限流排队机制,保护服务器在高并发场景下的稳定性。
|
||||
|
||||
#### 核心功能
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 🚦 全局限流 | 限制全站每秒最多10次MySQL查询,超出后触发排队 |
|
||||
| 📊 排队页面 | iOS风格排队页面,显示队列人数、预计等待时间 |
|
||||
| 🔐 验证码插队 | 简单数学验证码,验证通过后30分钟内免排队 |
|
||||
| 🎯 客户端免排队 | 闲言APP客户端请求(带X-Client Header)不受限流影响 |
|
||||
| 👤 登录用户免排队 | 已登录的网页用户自动跳过排队 |
|
||||
| 🆕 首次访问免排队 | 用户分享的URL直接打开时免排队(60秒内有效) |
|
||||
| ⚙️ 白名单机制 | 静态资源、后台、API路径、特定工具页面免限流 |
|
||||
|
||||
#### 新增文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `application/extra/rate_limit.php` | 限流配置文件(可调阈值、白名单、Cookie有效期) |
|
||||
| `application/common/behavior/RateLimitBehavior.php` | 限流行为类(核心逻辑:计数器、白名单、排队页面渲染) |
|
||||
| `application/index/controller/Queue.php` | 排队控制器(check/captcha/verify/status接口) |
|
||||
| `application/index/view/queue/index.html` | 排队页面模板(iOS风格,自动轮询,验证码输入) |
|
||||
| `Scripts/test_rate_limit.py` | Python测试脚本(8个测试场景) |
|
||||
| `Scripts/upload_rate_limit.py` | 代码上传脚本 |
|
||||
|
||||
#### 修改文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `application/tags.php` | 注册 RateLimitBehavior 到 module_init 钩子 |
|
||||
| `application/route.php` | 添加排队相关路由(/queue/check, /queue/captcha, /queue/verify, /queue/status) |
|
||||
|
||||
#### 技术实现
|
||||
|
||||
- **计数器存储**:文件计数器(runtime/rate_limit/counter_*.txt)
|
||||
- **时间窗口**:固定1秒窗口
|
||||
- **排队页面**:503状态码 + Retry-After头 + HTML页面
|
||||
- **验证码**:数学题(如 3+5=?),验证通过后设置Cookie(30分钟有效)
|
||||
- **白名单**:静态资源扩展名、路径前缀、控制器、具体工具页面、IP
|
||||
|
||||
#### 测试验证
|
||||
|
||||
- 单请求正常访问 ✅
|
||||
- 并发请求触发排队 ✅
|
||||
- API客户端跳过排队 ✅
|
||||
- 验证码生成与验证 ✅
|
||||
- 队列状态检查 ✅
|
||||
- 白名单路径免限流 ✅
|
||||
- 排队页面内容验证 ✅
|
||||
|
||||
#### 配置说明
|
||||
|
||||
配置文件:`application/extra/rate_limit.php`
|
||||
|
||||
```php
|
||||
return [
|
||||
'enabled' => true, // 是否启用
|
||||
'max_queries_per_second' => 10, // 每秒最大查询次数
|
||||
'time_window' => 1, // 时间窗口(秒)
|
||||
'captcha_valid_duration' => 1800, // 验证码免排队有效期(30分钟)
|
||||
'first_visit_duration' => 60, // 首次访问免排队有效期(60秒)
|
||||
'queue_poll_interval' => 2000, // 排队页面轮询间隔(毫秒)
|
||||
'queue_max_wait' => 300, // 最大等待时间(秒)
|
||||
];
|
||||
```
|
||||
|
||||
#### 使用方式
|
||||
|
||||
1. **上传代码到服务器**:
|
||||
```bash
|
||||
python Scripts/upload_rate_limit.py
|
||||
```
|
||||
|
||||
2. **运行测试脚本**:
|
||||
```bash
|
||||
python Scripts/test_rate_limit.py
|
||||
```
|
||||
|
||||
3. **浏览器验证**:
|
||||
- 访问 https://tools.wktyl.com/
|
||||
- 快速刷新页面触发排队
|
||||
- 输入验证码插队
|
||||
|
||||
---
|
||||
|
||||
## v10.0.0 (2026-06-01)
|
||||
|
||||
### 📚 新增稍后读/字体同步/插件更新 API接口文档
|
||||
|
||||
687
docs/toolsapi/application/common/behavior/RateLimitBehavior.php
Normal file
687
docs/toolsapi/application/common/behavior/RateLimitBehavior.php
Normal file
@@ -0,0 +1,687 @@
|
||||
<?php
|
||||
/**
|
||||
* 全局限流排队行为类
|
||||
* 创建时间: 2026-06-25
|
||||
* 更新时间: 2026-06-25
|
||||
* 作用: 在每次请求时检查是否需要限流排队
|
||||
* 触发时机: module_init(模块初始化前)
|
||||
*/
|
||||
|
||||
namespace app\common\behavior;
|
||||
|
||||
use think\Config;
|
||||
use think\Cookie;
|
||||
use think\Request;
|
||||
use think\Response;
|
||||
use think\Session;
|
||||
use think\exception\HttpResponseException;
|
||||
|
||||
class RateLimitBehavior
|
||||
{
|
||||
/**
|
||||
* 限流配置
|
||||
*/
|
||||
private $config = [];
|
||||
|
||||
/**
|
||||
* 计数器目录
|
||||
*/
|
||||
private $counterDir = '';
|
||||
|
||||
/**
|
||||
* 当前秒的时间戳
|
||||
*/
|
||||
private $currentSecond = 0;
|
||||
|
||||
/**
|
||||
* 当前秒的计数器文件
|
||||
*/
|
||||
private $counterFile = '';
|
||||
|
||||
/**
|
||||
* 队列计数器文件
|
||||
*/
|
||||
private $queueFile = '';
|
||||
|
||||
/**
|
||||
* 行为入口
|
||||
* @param mixed $params
|
||||
*/
|
||||
public function moduleInit(&$params)
|
||||
{
|
||||
try {
|
||||
// 加载配置
|
||||
$this->config = Config::get('rate_limit');
|
||||
if (empty($this->config) || !is_array($this->config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 未启用则跳过
|
||||
if (empty($this->config['enabled'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request = Request::instance();
|
||||
|
||||
// 检查是否在白名单中(免限流)
|
||||
if ($this->isWhitelisted($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已登录用户(免排队)
|
||||
if ($this->isLoggedInUser($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有免排队Cookie(验证码通过后30分钟内免排队)
|
||||
if ($this->hasBypassCookie($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化计数器路径
|
||||
$this->initCounterPaths();
|
||||
|
||||
// 先递增计数器,再获取计数(避免竞态条件)
|
||||
$this->incrementCounter();
|
||||
$queryCount = $this->getQueryCount();
|
||||
|
||||
$maxQueries = isset($this->config['max_queries_per_second']) ? intval($this->config['max_queries_per_second']) : 10;
|
||||
|
||||
if ($queryCount >= $maxQueries) {
|
||||
// 超出限制,检查是否首次访问(首次访问即使超限也放行,但仍计数)
|
||||
if ($this->isFirstVisit($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 非首次访问,需要排队 - 抛出异常中断请求
|
||||
$response = $this->handleQueue($request);
|
||||
throw new HttpResponseException($response);
|
||||
}
|
||||
|
||||
// 未超限,检查首次访问(设置cookie以便后续识别)
|
||||
$this->isFirstVisit($request);
|
||||
} catch (HttpResponseException $e) {
|
||||
// 重新抛出 HttpResponseException
|
||||
throw $e;
|
||||
} catch (\Exception $e) {
|
||||
// 其他异常静默处理,不影响正常请求
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否在白名单中
|
||||
* @param Request $request
|
||||
* @return bool
|
||||
*/
|
||||
private function isWhitelisted($request)
|
||||
{
|
||||
$whitelist = isset($this->config['whitelist']) ? $this->config['whitelist'] : [];
|
||||
|
||||
// 1. 检查静态资源扩展名
|
||||
$uri = $request->url();
|
||||
$path = parse_url($uri, PHP_URL_PATH);
|
||||
if (!$path) {
|
||||
return false;
|
||||
}
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
if (!empty($whitelist['static_extensions']) && is_array($whitelist['static_extensions'])) {
|
||||
if (in_array('.' . $ext, $whitelist['static_extensions'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查路径前缀
|
||||
$requestPath = '/' . ltrim($path, '/');
|
||||
if (!empty($whitelist['path_prefixes']) && is_array($whitelist['path_prefixes'])) {
|
||||
foreach ($whitelist['path_prefixes'] as $prefix) {
|
||||
if (strpos($requestPath, $prefix) === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查免限流的控制器
|
||||
$module = $request->module();
|
||||
$controller = strtolower($request->controller());
|
||||
|
||||
if ($module === 'index' && !empty($whitelist['exempt_controllers']) && is_array($whitelist['exempt_controllers'])) {
|
||||
if (in_array($controller, $whitelist['exempt_controllers'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 检查免限流的具体工具页面
|
||||
if ($module === 'index' && $controller === 'tool') {
|
||||
$action = strtolower($request->action());
|
||||
if (!empty($whitelist['exempt_tools']) && is_array($whitelist['exempt_tools'])) {
|
||||
if (in_array($action, $whitelist['exempt_tools'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 检查IP白名单
|
||||
$ip = $request->ip();
|
||||
if (!empty($whitelist['ip_whitelist']) && is_array($whitelist['ip_whitelist'])) {
|
||||
if (in_array($ip, $whitelist['ip_whitelist'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已登录用户
|
||||
* @param Request $request
|
||||
* @return bool
|
||||
*/
|
||||
private function isLoggedInUser($request)
|
||||
{
|
||||
// 检查session中是否有用户信息
|
||||
$userId = Session::get('user_id');
|
||||
if (!$userId) {
|
||||
$userId = Session::get('userid');
|
||||
}
|
||||
|
||||
if ($userId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查cookie中的token
|
||||
$token = $request->cookie('token');
|
||||
if ($token) {
|
||||
try {
|
||||
$auth = \app\common\library\Auth::instance();
|
||||
$auth->init($token);
|
||||
if ($auth->isLogin()) {
|
||||
return true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 忽略异常
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有免排队Cookie
|
||||
* @param Request $request
|
||||
* @return bool
|
||||
*/
|
||||
private function hasBypassCookie($request)
|
||||
{
|
||||
$cookieName = isset($this->config['bypass_cookie_name']) ? $this->config['bypass_cookie_name'] : 'queue_bypass';
|
||||
$cookieValue = $request->cookie($cookieName);
|
||||
|
||||
if (!$cookieValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证cookie格式:timestamp_sign
|
||||
$parts = explode('_', $cookieValue);
|
||||
if (count($parts) !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$timestamp = intval($parts[0]);
|
||||
$sign = $parts[1];
|
||||
|
||||
// 验证签名
|
||||
$dbConfig = Config::get('database');
|
||||
$dbName = isset($dbConfig['database']) ? $dbConfig['database'] : '';
|
||||
$expectedSign = md5($timestamp . '_' . $dbName);
|
||||
|
||||
if ($sign !== $expectedSign) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
$validDuration = isset($this->config['captcha_valid_duration']) ? intval($this->config['captcha_valid_duration']) : 1800;
|
||||
if (time() - $timestamp > $validDuration) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否首次访问(已禁用)
|
||||
* 用户要求取消首次访问免排队逻辑
|
||||
* @param Request $request
|
||||
* @return bool
|
||||
*/
|
||||
private function isFirstVisit($request)
|
||||
{
|
||||
// 已禁用首次访问免排队逻辑
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化计数器路径
|
||||
*/
|
||||
private function initCounterPaths()
|
||||
{
|
||||
$counterPath = isset($this->config['counter_path']) ? $this->config['counter_path'] : RUNTIME_PATH . 'rate_limit/';
|
||||
$this->counterDir = rtrim($counterPath, '/');
|
||||
|
||||
$timeWindow = isset($this->config['time_window']) ? intval($this->config['time_window']) : 1;
|
||||
$this->currentSecond = floor(time() / $timeWindow);
|
||||
$this->counterFile = $this->counterDir . '/counter_' . $this->currentSecond . '.txt';
|
||||
$this->queueFile = isset($this->config['queue_counter_file']) ? $this->config['queue_counter_file'] : RUNTIME_PATH . 'rate_limit/queue_count.txt';
|
||||
|
||||
// 确保目录存在
|
||||
if (!is_dir($this->counterDir)) {
|
||||
@mkdir($this->counterDir, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前秒的查询次数
|
||||
* @return int
|
||||
*/
|
||||
private function getQueryCount()
|
||||
{
|
||||
if (!file_exists($this->counterFile)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$content = @file_get_contents($this->counterFile);
|
||||
if ($content === false) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return intval(trim($content));
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加计数器
|
||||
*/
|
||||
private function incrementCounter()
|
||||
{
|
||||
// 使用文件锁确保原子性
|
||||
$fp = @fopen($this->counterFile, 'c+');
|
||||
if (!$fp) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (flock($fp, LOCK_EX)) {
|
||||
$content = @fread($fp, 100);
|
||||
$count = intval(trim($content));
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, strval($count + 1));
|
||||
flock($fp, LOCK_UN);
|
||||
}
|
||||
|
||||
fclose($fp);
|
||||
|
||||
// 清理过期的计数器文件
|
||||
$this->cleanupOldCounters();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的计数器文件
|
||||
*/
|
||||
private function cleanupOldCounters()
|
||||
{
|
||||
// 每10次请求执行一次清理
|
||||
if (rand(1, 10) !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = glob($this->counterDir . '/counter_*.txt');
|
||||
if (!$files) {
|
||||
return;
|
||||
}
|
||||
$currentSecond = $this->currentSecond;
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (preg_match('/counter_(\d+)\.txt/', basename($file), $matches)) {
|
||||
$fileSecond = intval($matches[1]);
|
||||
// 删除1秒前的计数器文件
|
||||
if ($fileSecond < $currentSecond) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理排队逻辑
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
*/
|
||||
private function handleQueue($request)
|
||||
{
|
||||
// 增加队列计数器
|
||||
$queueCount = $this->incrementQueueCounter();
|
||||
|
||||
// 生成排队页面
|
||||
$html = $this->renderQueuePage($queueCount, $request);
|
||||
|
||||
// 返回503状态码
|
||||
return Response::create($html, 'html', 503)
|
||||
->header([
|
||||
'Retry-After' => '5',
|
||||
'Cache-Control' => 'no-cache, no-store, must-revalidate',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加队列计数器
|
||||
* @return int
|
||||
*/
|
||||
private function incrementQueueCounter()
|
||||
{
|
||||
$fp = @fopen($this->queueFile, 'c+');
|
||||
if (!$fp) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
if (flock($fp, LOCK_EX)) {
|
||||
$content = @fread($fp, 1000);
|
||||
$data = json_decode($content, true);
|
||||
if (!$data || !is_array($data)) {
|
||||
$data = ['count' => 0, 'updated' => 0];
|
||||
}
|
||||
|
||||
// 如果超过10秒未更新,重置计数器
|
||||
if (time() - $data['updated'] > 10) {
|
||||
$data['count'] = 0;
|
||||
}
|
||||
|
||||
$data['count']++;
|
||||
$data['updated'] = time();
|
||||
$count = $data['count'];
|
||||
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, json_encode($data));
|
||||
flock($fp, LOCK_UN);
|
||||
}
|
||||
|
||||
fclose($fp);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染排队页面
|
||||
* @param int $queueCount
|
||||
* @param Request $request
|
||||
* @return string
|
||||
*/
|
||||
private function renderQueuePage($queueCount, $request)
|
||||
{
|
||||
$pollInterval = isset($this->config['queue_poll_interval']) ? intval($this->config['queue_poll_interval']) : 2000;
|
||||
$maxWait = isset($this->config['queue_max_wait']) ? intval($this->config['queue_max_wait']) : 300;
|
||||
$originalUrl = htmlspecialchars($request->url());
|
||||
|
||||
// 生成数学验证码
|
||||
$num1 = rand(1, 20);
|
||||
$num2 = rand(1, 20);
|
||||
$answer = $num1 + $num2;
|
||||
|
||||
$dbConfig = Config::get('database');
|
||||
$dbName = isset($dbConfig['database']) ? $dbConfig['database'] : '';
|
||||
$timestamp = time();
|
||||
$captchaSign = md5($answer . '_' . $timestamp . '_' . $dbName);
|
||||
|
||||
// 将验证码答案存入临时文件
|
||||
$captchaDir = RUNTIME_PATH . 'rate_limit/';
|
||||
if (!is_dir($captchaDir)) {
|
||||
@mkdir($captchaDir, 0755, true);
|
||||
}
|
||||
$captchaFile = $captchaDir . 'captcha_' . md5($captchaSign) . '.txt';
|
||||
@file_put_contents($captchaFile, json_encode(array(
|
||||
'answer' => $answer,
|
||||
'created' => $timestamp
|
||||
)));
|
||||
|
||||
// 清理过期验证码文件
|
||||
$this->cleanupExpiredCaptchas($captchaDir);
|
||||
|
||||
$html = <<<HTML
|
||||
<!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">
|
||||
<title>系统繁忙 - 请稍等</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Arial, 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: rgba(255,255,255,0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-radius: 24px;
|
||||
padding: 40px 30px;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
text-align: center;
|
||||
}
|
||||
.icon { font-size: 64px; margin-bottom: 20px; animation: bounce 2s infinite; }
|
||||
@keyframes bounce { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-10px)} }
|
||||
h1 { font-size: 24px; font-weight: 600; color: #1c1c1e; margin-bottom: 12px; }
|
||||
.subtitle { font-size: 15px; color: #8e8e93; margin-bottom: 30px; line-height: 1.5; }
|
||||
.queue-info {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
border-radius: 16px; padding: 20px; margin-bottom: 24px; color: white;
|
||||
}
|
||||
.queue-count { font-size: 48px; font-weight: 700; line-height: 1; margin-bottom: 8px; }
|
||||
.queue-label { font-size: 14px; opacity: 0.9; }
|
||||
.wait-time { background: #f2f2f7; border-radius: 12px; padding: 16px; margin-bottom: 24px; }
|
||||
.wait-time-label { font-size: 13px; color: #8e8e93; margin-bottom: 4px; }
|
||||
.wait-time-value { font-size: 20px; font-weight: 600; color: #1c1c1e; }
|
||||
.captcha-section { background: #f2f2f7; border-radius: 16px; padding: 20px; margin-bottom: 20px; }
|
||||
.captcha-title { font-size: 15px; font-weight: 600; color: #1c1c1e; margin-bottom: 12px; }
|
||||
.captcha-question { font-size: 32px; font-weight: 700; color: #667eea; margin-bottom: 16px; letter-spacing: 2px; }
|
||||
.captcha-input {
|
||||
width: 100%; padding: 14px 16px; font-size: 18px; font-weight: 600;
|
||||
text-align: center; border: 2px solid #e5e5ea; border-radius: 12px;
|
||||
outline: none; transition: all 0.3s; margin-bottom: 12px;
|
||||
}
|
||||
.captcha-input:focus { border-color: #667eea; background: white; }
|
||||
.btn {
|
||||
width: 100%; padding: 16px; font-size: 17px; font-weight: 600;
|
||||
border: none; border-radius: 14px; cursor: pointer; transition: all 0.3s; margin-bottom: 12px;
|
||||
}
|
||||
.btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
|
||||
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 10px 20px rgba(102,126,234,0.4); }
|
||||
.btn-primary:active { transform: translateY(0); }
|
||||
.btn-secondary { background: #e5e5ea; color: #1c1c1e; }
|
||||
.btn-secondary:hover { background: #d1d1d6; }
|
||||
.progress-bar { width: 100%; height: 4px; background: #e5e5ea; border-radius: 2px; overflow: hidden; margin-top: 20px; }
|
||||
.progress-fill { height: 100%; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); border-radius: 2px; animation: progress 2s infinite; }
|
||||
@keyframes progress { 0%{width:0} 50%{width:100%} 100%{width:0} }
|
||||
.footer-text { font-size: 13px; color: #8e8e93; margin-top: 20px; line-height: 1.5; }
|
||||
.error-msg { color: #ff3b30; font-size: 14px; margin-top: 8px; display: none; }
|
||||
.success-msg { color: #34c759; font-size: 14px; margin-top: 8px; display: none; }
|
||||
@media (max-width: 480px) {
|
||||
.container { padding: 30px 20px; }
|
||||
h1 { font-size: 22px; }
|
||||
.queue-count { font-size: 40px; }
|
||||
.captcha-question { font-size: 28px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">🚦</div>
|
||||
<h1>系统繁忙中</h1>
|
||||
<p class="subtitle">当前访问人数较多,您的请求正在排队处理</p>
|
||||
<div class="queue-info">
|
||||
<div class="queue-count" id="queueCount">{$queueCount}</div>
|
||||
<div class="queue-label">人正在排队</div>
|
||||
</div>
|
||||
<div class="wait-time">
|
||||
<div class="wait-time-label">预计等待时间</div>
|
||||
<div class="wait-time-value" id="waitTime">计算中...</div>
|
||||
</div>
|
||||
<div class="captcha-section">
|
||||
<div class="captcha-title">🔐 输入验证码可立即进入</div>
|
||||
<div class="captcha-question">{$num1} + {$num2} = ?</div>
|
||||
<input type="number" class="captcha-input" id="captchaInput" placeholder="请输入答案" autocomplete="off">
|
||||
<div class="error-msg" id="errorMsg">答案错误,请重试</div>
|
||||
<div class="success-msg" id="successMsg">验证成功,正在跳转...</div>
|
||||
<button class="btn btn-primary" id="submitBtn" onclick="submitCaptcha()">验证并进入</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick="manualRefresh()">🔄 手动刷新队列</button>
|
||||
<div class="progress-bar"><div class="progress-fill"></div></div>
|
||||
<p class="footer-text">
|
||||
页面将自动刷新,您也可以输入验证码快速进入<br>
|
||||
登录用户可免排队
|
||||
</p>
|
||||
</div>
|
||||
<script>
|
||||
var originalUrl = "{$originalUrl}";
|
||||
var captchaSign = "{$captchaSign}";
|
||||
var pollInterval = {$pollInterval};
|
||||
var maxWait = {$maxWait};
|
||||
|
||||
function pollQueue() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/queue/check', true);
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4 && xhr.status === 200) {
|
||||
try {
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
if (data.code === 1) {
|
||||
document.getElementById('queueCount').textContent = data.data.queue_count;
|
||||
updateWaitTime(data.data.queue_count);
|
||||
if (data.data.queue_count === 0) {
|
||||
window.location.href = originalUrl;
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function updateWaitTime(queueCount) {
|
||||
var seconds = Math.max(1, Math.ceil(queueCount * 0.5));
|
||||
var minutes = Math.floor(seconds / 60);
|
||||
var secs = seconds % 60;
|
||||
var el = document.getElementById('waitTime');
|
||||
if (minutes > 0) {
|
||||
el.textContent = '约 ' + minutes + ' 分 ' + secs + ' 秒';
|
||||
} else {
|
||||
el.textContent = '约 ' + secs + ' 秒';
|
||||
}
|
||||
}
|
||||
|
||||
function submitCaptcha() {
|
||||
var input = document.getElementById('captchaInput').value;
|
||||
var errorMsg = document.getElementById('errorMsg');
|
||||
var successMsg = document.getElementById('successMsg');
|
||||
var submitBtn = document.getElementById('submitBtn');
|
||||
|
||||
errorMsg.style.display = 'none';
|
||||
successMsg.style.display = 'none';
|
||||
|
||||
if (!input) {
|
||||
errorMsg.textContent = '请输入答案';
|
||||
errorMsg.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '验证中...';
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/queue/verify', true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4) {
|
||||
try {
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
if (data.code === 1) {
|
||||
successMsg.style.display = 'block';
|
||||
submitBtn.textContent = '验证成功';
|
||||
setTimeout(function() {
|
||||
window.location.href = originalUrl;
|
||||
}, 1000);
|
||||
} else {
|
||||
errorMsg.textContent = data.msg || '答案错误,请重试';
|
||||
errorMsg.style.display = 'block';
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '验证并进入';
|
||||
document.getElementById('captchaInput').value = '';
|
||||
}
|
||||
} catch(e) {
|
||||
errorMsg.textContent = '网络错误,请重试';
|
||||
errorMsg.style.display = 'block';
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '验证并进入';
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.send('answer=' + encodeURIComponent(input) + '&sign=' + encodeURIComponent(captchaSign));
|
||||
}
|
||||
|
||||
function manualRefresh() {
|
||||
pollQueue();
|
||||
}
|
||||
|
||||
document.getElementById('captchaInput').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') { submitCaptcha(); }
|
||||
});
|
||||
|
||||
setInterval(pollQueue, pollInterval);
|
||||
|
||||
setTimeout(function() {
|
||||
var countEl = document.getElementById('queueCount');
|
||||
if (countEl && parseInt(countEl.textContent) > 0) {
|
||||
var footer = document.querySelector('.footer-text');
|
||||
if (footer) {
|
||||
footer.innerHTML += '<br><strong style="color:#ff9500;">等待时间较长,建议稍后再试</strong>';
|
||||
}
|
||||
}
|
||||
}, maxWait * 1000);
|
||||
|
||||
updateWaitTime({$queueCount});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期验证码文件
|
||||
* @param string $captchaDir
|
||||
*/
|
||||
private function cleanupExpiredCaptchas($captchaDir)
|
||||
{
|
||||
$files = glob($captchaDir . 'captcha_*.txt');
|
||||
if (!$files) {
|
||||
return;
|
||||
}
|
||||
$expireTime = time() - 300; // 5分钟
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (filemtime($file) < $expireTime) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
107
docs/toolsapi/application/extra/rate_limit.php
Normal file
107
docs/toolsapi/application/extra/rate_limit.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
/**
|
||||
* 全局限流排队配置文件
|
||||
* 创建时间: 2026-06-25
|
||||
* 更新时间: 2026-06-25
|
||||
* 作用: 控制全站网页端请求的MySQL查询频率限制
|
||||
*/
|
||||
|
||||
return [
|
||||
// 是否启用限流排队
|
||||
'enabled' => true,
|
||||
|
||||
// 每秒最大MySQL查询次数(全局)
|
||||
'max_queries_per_second' => 3,
|
||||
|
||||
// 时间窗口(秒),固定窗口
|
||||
'time_window' => 1,
|
||||
|
||||
// 计数器存储路径
|
||||
'counter_path' => RUNTIME_PATH . 'rate_limit/',
|
||||
|
||||
// 队列计数器文件
|
||||
'queue_counter_file' => RUNTIME_PATH . 'rate_limit/queue_count.txt',
|
||||
|
||||
// 验证码有效期(秒),30分钟
|
||||
'captcha_valid_duration' => 1800,
|
||||
|
||||
// Cookie名称:验证通过后免排队
|
||||
'bypass_cookie_name' => 'queue_bypass',
|
||||
|
||||
// 白名单配置
|
||||
'whitelist' => [
|
||||
// 静态资源扩展名(不计数不限流)
|
||||
'static_extensions' => ['.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.svg', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.webp', '.mp4', '.mp3'],
|
||||
|
||||
// 免限流的模块/控制器路径前缀
|
||||
'path_prefixes' => [
|
||||
'/admin', // 后台管理
|
||||
'/api/', // API接口(客户端请求)
|
||||
'/health', // 健康检查
|
||||
'/queue/', // 排队页面自身
|
||||
'/captcha', // 验证码接口
|
||||
],
|
||||
|
||||
// 免限流的页面类型(index模块下的控制器)
|
||||
'exempt_controllers' => [
|
||||
// 用户认证页面
|
||||
'user', // 登录/注册/找回密码等
|
||||
|
||||
// 静态内容页面
|
||||
'about', // 关于我们
|
||||
'link', // 友情链接
|
||||
'doc', // 文档页面
|
||||
|
||||
// 纯计算工具页面(不查数据库)
|
||||
'tool', // 工具页面(部分工具是纯计算)
|
||||
],
|
||||
|
||||
// 免限流的具体工具页面(tool控制器下的action)
|
||||
'exempt_tools' => [
|
||||
// 纯计算类工具
|
||||
'calculator', 'bmi', 'age', 'angle', 'bazi', 'bazipaipan',
|
||||
'bosongfenbu', 'brick', 'car_sign', 'changshi', 'chengfabiao',
|
||||
'concrete', 'country', 'cube_root', 'curtain', 'daxiaoxie',
|
||||
'decoration', 'edd', 'ershoufang', 'excel', 'eyesight',
|
||||
'favicon', 'floors', 'gongjijin', 'grsds', 'hexagon',
|
||||
'hexagon', 'huangli', 'id_soft', 'image_to_base', 'jianfan',
|
||||
'jiugongge', 'jjjsq', 'jueduizhi', 'led', 'lingcunzhengqu',
|
||||
'lipstick', 'lixi', 'loan', 'loans', 'manicure', 'mdq_quiz',
|
||||
'mingpian', 'minority', 'modulo', 'mortgage', 'moyu',
|
||||
'muscle', 'named', 'necktie', 'nevoid_phase', 'nick',
|
||||
'nzjgrsds', 'ocr', 'paint', 'percentage', 'periodic_table',
|
||||
'photo_compression', 'picseal', 'prepayment', 'qianziwen',
|
||||
'qr', 'qr_parse', 'raokouling', 'rec_audio', 'relative',
|
||||
'rmbzdx', 'rmbzmydx', 'safe_color', 'safe_period',
|
||||
'shengxiao', 'suoxie', 'taiyu', 'techartgroup', 'today',
|
||||
'universe', 'warring', 'xingjiaozishi', 'yidaliyu',
|
||||
'yingyu', 'yinniyu', 'yuenanyu', 'zangyu', 'zhongyaocai',
|
||||
'zhongyong', 'zhouyi', 'zhuangzi', 'zizhitongjian',
|
||||
'zuci', 'zuowen', 'zuozhuan',
|
||||
|
||||
// 错误页面
|
||||
'404', 'error',
|
||||
],
|
||||
|
||||
// 免限流的IP(服务器IP等)
|
||||
'ip_whitelist' => [
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
],
|
||||
],
|
||||
|
||||
// 首次访问免排队(用户分享的URL直接打开)
|
||||
'first_visit_bypass' => true,
|
||||
|
||||
// 首次访问Cookie名称
|
||||
'first_visit_cookie' => 'first_visit_bypass',
|
||||
|
||||
// 首次访问Cookie有效期(秒),60秒
|
||||
'first_visit_duration' => 60,
|
||||
|
||||
// 排队页面自动轮询间隔(毫秒)
|
||||
'queue_poll_interval' => 2000,
|
||||
|
||||
// 排队页面最大等待时间(秒),超过后提示用户稍后再试
|
||||
'queue_max_wait' => 300,
|
||||
];
|
||||
213
docs/toolsapi/application/index/controller/Queue.php
Normal file
213
docs/toolsapi/application/index/controller/Queue.php
Normal file
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
/**
|
||||
* 排队控制器
|
||||
* 创建时间: 2026-06-25
|
||||
* 更新时间: 2026-06-25
|
||||
* 作用: 处理排队页面的验证码验证和队列状态查询
|
||||
*/
|
||||
|
||||
namespace app\index\controller;
|
||||
|
||||
use think\Config;
|
||||
use think\Cookie;
|
||||
use think\Request;
|
||||
use think\Controller;
|
||||
|
||||
class Queue extends Controller
|
||||
{
|
||||
/**
|
||||
* 检查队列状态
|
||||
* GET /queue/check
|
||||
*/
|
||||
public function check()
|
||||
{
|
||||
$config = Config::get('rate_limit');
|
||||
if (!$config || !is_array($config)) {
|
||||
$config = [];
|
||||
}
|
||||
$queueFile = isset($config['queue_counter_file']) ? $config['queue_counter_file'] : RUNTIME_PATH . 'rate_limit/queue_count.txt';
|
||||
|
||||
$queueCount = 0;
|
||||
|
||||
if (file_exists($queueFile)) {
|
||||
$content = @file_get_contents($queueFile);
|
||||
if ($content !== false && $content !== '') {
|
||||
$data = json_decode($content, true);
|
||||
if (!$data || !is_array($data)) {
|
||||
$data = array('count' => 0, 'updated' => 0);
|
||||
}
|
||||
} else {
|
||||
$data = array('count' => 0, 'updated' => 0);
|
||||
}
|
||||
|
||||
// 如果超过10秒未更新,认为队列已清空
|
||||
if (time() - $data['updated'] > 10) {
|
||||
$queueCount = 0;
|
||||
} else {
|
||||
$queueCount = isset($data['count']) ? intval($data['count']) : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 计算预计等待时间(每人约0.5秒)
|
||||
$waitSeconds = max(1, ceil($queueCount * 0.5));
|
||||
$maxQueries = isset($config['max_queries_per_second']) ? intval($config['max_queries_per_second']) : 10;
|
||||
|
||||
return json(array(
|
||||
'code' => 1,
|
||||
'msg' => 'ok',
|
||||
'data' => array(
|
||||
'queue_count' => $queueCount,
|
||||
'wait_seconds' => $waitSeconds,
|
||||
'max_queries_per_second' => $maxQueries
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成验证码
|
||||
* GET /queue/captcha
|
||||
*/
|
||||
public function captcha()
|
||||
{
|
||||
// 生成数学题
|
||||
$num1 = rand(1, 20);
|
||||
$num2 = rand(1, 20);
|
||||
$answer = $num1 + $num2;
|
||||
|
||||
// 生成签名
|
||||
$timestamp = time();
|
||||
$dbConfig = Config::get('database');
|
||||
$dbName = isset($dbConfig['database']) ? $dbConfig['database'] : '';
|
||||
$sign = md5($answer . '_' . $timestamp . '_' . $dbName);
|
||||
|
||||
// 存储验证码答案到临时文件
|
||||
$captchaDir = RUNTIME_PATH . 'rate_limit/';
|
||||
if (!is_dir($captchaDir)) {
|
||||
@mkdir($captchaDir, 0755, true);
|
||||
}
|
||||
|
||||
// 使用 sign 本身作为文件名(不再二次 md5)
|
||||
$captchaFile = $captchaDir . 'captcha_' . $sign . '.txt';
|
||||
@file_put_contents($captchaFile, json_encode(array(
|
||||
'answer' => $answer,
|
||||
'created' => $timestamp
|
||||
)));
|
||||
|
||||
return json(array(
|
||||
'code' => 1,
|
||||
'msg' => 'ok',
|
||||
'data' => array(
|
||||
'question' => $num1 . ' + ' . $num2 . ' = ?',
|
||||
'sign' => $sign,
|
||||
'expire' => 300
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证验证码
|
||||
* POST /queue/verify
|
||||
*/
|
||||
public function verify()
|
||||
{
|
||||
$request = Request::instance();
|
||||
$answer = $request->post('answer');
|
||||
$sign = $request->post('sign');
|
||||
|
||||
if (!$answer || !$sign) {
|
||||
return json(array('code' => 0, 'msg' => 'param error'));
|
||||
}
|
||||
|
||||
// 验证签名格式
|
||||
if (!preg_match('/^[a-f0-9]{32}$/', $sign)) {
|
||||
return json(array('code' => 0, 'msg' => 'sign format error'));
|
||||
}
|
||||
|
||||
// 读取验证码文件(使用 sign 本身作为文件名)
|
||||
$captchaFile = RUNTIME_PATH . 'rate_limit/captcha_' . $sign . '.txt';
|
||||
|
||||
if (!file_exists($captchaFile)) {
|
||||
return json(array('code' => 0, 'msg' => 'captcha expired'));
|
||||
}
|
||||
|
||||
$content = @file_get_contents($captchaFile);
|
||||
$data = json_decode($content, true);
|
||||
|
||||
if (!$data || !isset($data['answer']) || !isset($data['created'])) {
|
||||
@unlink($captchaFile);
|
||||
return json(array('code' => 0, 'msg' => 'captcha data error'));
|
||||
}
|
||||
|
||||
// 检查是否过期(5分钟)
|
||||
if (time() - $data['created'] > 300) {
|
||||
@unlink($captchaFile);
|
||||
return json(array('code' => 0, 'msg' => 'captcha expired'));
|
||||
}
|
||||
|
||||
// 验证答案
|
||||
if (intval($answer) !== intval($data['answer'])) {
|
||||
return json(array('code' => 0, 'msg' => 'wrong answer'));
|
||||
}
|
||||
|
||||
// 验证通过,删除验证码文件
|
||||
@unlink($captchaFile);
|
||||
|
||||
// 设置免排队Cookie(30分钟有效)
|
||||
$config = Config::get('rate_limit');
|
||||
if (!$config || !is_array($config)) {
|
||||
$config = array();
|
||||
}
|
||||
$cookieName = isset($config['bypass_cookie_name']) ? $config['bypass_cookie_name'] : 'queue_bypass';
|
||||
$validDuration = isset($config['captcha_valid_duration']) ? intval($config['captcha_valid_duration']) : 1800;
|
||||
|
||||
$timestamp = time();
|
||||
$dbConfig = Config::get('database');
|
||||
$dbName = isset($dbConfig['database']) ? $dbConfig['database'] : '';
|
||||
$cookieSign = md5($timestamp . '_' . $dbName);
|
||||
$cookieValue = $timestamp . '_' . $cookieSign;
|
||||
|
||||
Cookie::set($cookieName, $cookieValue, $validDuration);
|
||||
|
||||
return json(array(
|
||||
'code' => 1,
|
||||
'msg' => 'ok',
|
||||
'data' => array(
|
||||
'bypass_duration' => $validDuration
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 队列状态
|
||||
* GET /queue/status
|
||||
*/
|
||||
public function status()
|
||||
{
|
||||
$config = Config::get('rate_limit');
|
||||
if (!$config || !is_array($config)) {
|
||||
$config = array();
|
||||
}
|
||||
$counterPath = isset($config['counter_path']) ? $config['counter_path'] : RUNTIME_PATH . 'rate_limit/';
|
||||
$counterDir = rtrim($counterPath, '/');
|
||||
$timeWindow = isset($config['time_window']) ? intval($config['time_window']) : 1;
|
||||
$currentSecond = floor(time() / $timeWindow);
|
||||
$counterFile = $counterDir . '/counter_' . $currentSecond . '.txt';
|
||||
|
||||
$queryCount = 0;
|
||||
if (file_exists($counterFile)) {
|
||||
$queryCount = intval(@file_get_contents($counterFile));
|
||||
}
|
||||
|
||||
$maxQueries = isset($config['max_queries_per_second']) ? intval($config['max_queries_per_second']) : 10;
|
||||
|
||||
return json(array(
|
||||
'code' => 1,
|
||||
'msg' => 'ok',
|
||||
'data' => array(
|
||||
'current_queries' => $queryCount,
|
||||
'max_queries_per_second' => $maxQueries,
|
||||
'is_limited' => $queryCount >= $maxQueries
|
||||
)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,12 @@ Route::get('userdeletion','userdeletion/index');//用户注销申请页面
|
||||
Route::rule('userdeletion/apply','userdeletion/apply');//用户注销申请
|
||||
Route::rule('userdeletion/cancel','userdeletion/cancel');//撤销注销申请
|
||||
|
||||
// 排队限流相关(2026-06-25新增)
|
||||
Route::get('queue/check','queue/check');//检查队列状态
|
||||
Route::get('queue/captcha','queue/captcha');//生成验证码
|
||||
Route::post('queue/verify','queue/verify');//验证验证码
|
||||
Route::get('queue/status','queue/status');//队列状态
|
||||
|
||||
// 用户中心
|
||||
Route::rule([
|
||||
'user/index' => 'user/index',
|
||||
|
||||
@@ -24,6 +24,7 @@ return [
|
||||
// 模块初始化
|
||||
'module_init' => [
|
||||
'app\\common\\behavior\\Common',
|
||||
'app\\common\\behavior\\RateLimitBehavior', // 全局限流排队(2026-06-25新增)
|
||||
],
|
||||
// 插件开始
|
||||
'addon_begin' => [
|
||||
|
||||
Reference in New Issue
Block a user