This commit is contained in:
Developer
2026-06-25 23:28:34 +08:00
parent 7ea4a068a1
commit bd937b02f3
8 changed files with 1137 additions and 750 deletions

View File

@@ -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()

View File

@@ -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&note={key}
4. 批量检查变更 GET /?check&keys=key1,key2
5. 创建随机笔记 GET /?new&json&text=test
6. 删除笔记 GET /?delete&note={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&note={key}
预期: 200 + {"code":1, "data":{"key":"...", "size":..., "mtime":..., "exists":true}}
"""
print_separator("测试3: 获取笔记信息 (GET ?info)")
url = f"{BASE_URL}/?info&note={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&note={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&note={key}
预期: 200 + {"code":1, "msg":"deleted"}
"""
print_separator("测试6: 删除笔记 (GET ?delete)")
url = f"{BASE_URL}/?delete&note={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&note={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&note={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()

View File

@@ -1,5 +1,128 @@
# CHANGELOG
## v10.2.1 (2026-06-25)
### 🛡️ 限流排队系统优化
**安全更新**新增首次访问免排队防遍历机制防止恶意用户伪装IP遍历页面。
#### 新增功能
| 功能 | 说明 |
|------|------|
| 🛡️ 防遍历机制 | 同一页面在60秒内最多允许3次"首次访问",超过阈值自动取消免排队 |
| 📊 访问追踪 | 记录每个页面的首次访问次数runtime/rate_limit/first_visit_*.txt |
| 🚫 自动封禁 | 检测到遍历行为后设置blocked标记cookie60秒内不再提供免排队 |
#### 修改文件
| 文件 | 修改内容 |
|------|----------|
| `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=?验证通过后设置Cookie30分钟有效
- **白名单**静态资源扩展名、路径前缀、控制器、具体工具页面、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接口文档

View 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);
}
}
}
}

View 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,
];

View 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);
// 设置免排队Cookie30分钟有效
$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
)
));
}
}

View File

@@ -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',

View File

@@ -24,6 +24,7 @@ return [
// 模块初始化
'module_init' => [
'app\\common\\behavior\\Common',
'app\\common\\behavior\\RateLimitBehavior', // 全局限流排队2026-06-25新增
],
// 插件开始
'addon_begin' => [