From bd937b02f31855b68170622641a77887f21ef00b Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 25 Jun 2026 23:28:34 +0800 Subject: [PATCH] php --- Scripts/generate_tray_icons.py | 157 ---- Scripts/test_ctc_api.py | 593 --------------- docs/toolsapi/CHANGELOG.md | 123 ++++ .../common/behavior/RateLimitBehavior.php | 687 ++++++++++++++++++ .../toolsapi/application/extra/rate_limit.php | 107 +++ .../application/index/controller/Queue.php | 213 ++++++ docs/toolsapi/application/route.php | 6 + docs/toolsapi/application/tags.php | 1 + 8 files changed, 1137 insertions(+), 750 deletions(-) delete mode 100644 Scripts/generate_tray_icons.py delete mode 100644 Scripts/test_ctc_api.py create mode 100644 docs/toolsapi/application/common/behavior/RateLimitBehavior.php create mode 100644 docs/toolsapi/application/extra/rate_limit.php create mode 100644 docs/toolsapi/application/index/controller/Queue.php diff --git a/Scripts/generate_tray_icons.py b/Scripts/generate_tray_icons.py deleted file mode 100644 index d9d8c3a8..00000000 --- a/Scripts/generate_tray_icons.py +++ /dev/null @@ -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() diff --git a/Scripts/test_ctc_api.py b/Scripts/test_ctc_api.py deleted file mode 100644 index aa822915..00000000 --- a/Scripts/test_ctc_api.py +++ /dev/null @@ -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() diff --git a/docs/toolsapi/CHANGELOG.md b/docs/toolsapi/CHANGELOG.md index 5e04fb34..f3be4840 100644 --- a/docs/toolsapi/CHANGELOG.md +++ b/docs/toolsapi/CHANGELOG.md @@ -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接口文档 diff --git a/docs/toolsapi/application/common/behavior/RateLimitBehavior.php b/docs/toolsapi/application/common/behavior/RateLimitBehavior.php new file mode 100644 index 00000000..cc82e0b0 --- /dev/null +++ b/docs/toolsapi/application/common/behavior/RateLimitBehavior.php @@ -0,0 +1,687 @@ +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 = << + + + + + 系统繁忙 - 请稍等 + + + +
+
🚦
+

系统繁忙中

+

当前访问人数较多,您的请求正在排队处理

+
+
{$queueCount}
+
人正在排队
+
+
+
预计等待时间
+
计算中...
+
+
+
🔐 输入验证码可立即进入
+
{$num1} + {$num2} = ?
+ +
答案错误,请重试
+
验证成功,正在跳转...
+ +
+ +
+ +
+ + + +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); + } + } + } +} diff --git a/docs/toolsapi/application/extra/rate_limit.php b/docs/toolsapi/application/extra/rate_limit.php new file mode 100644 index 00000000..0b8b753f --- /dev/null +++ b/docs/toolsapi/application/extra/rate_limit.php @@ -0,0 +1,107 @@ + 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, +]; diff --git a/docs/toolsapi/application/index/controller/Queue.php b/docs/toolsapi/application/index/controller/Queue.php new file mode 100644 index 00000000..e38f5888 --- /dev/null +++ b/docs/toolsapi/application/index/controller/Queue.php @@ -0,0 +1,213 @@ + 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 + ) + )); + } +} diff --git a/docs/toolsapi/application/route.php b/docs/toolsapi/application/route.php index 5cf9f957..86bbac03 100644 --- a/docs/toolsapi/application/route.php +++ b/docs/toolsapi/application/route.php @@ -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', diff --git a/docs/toolsapi/application/tags.php b/docs/toolsapi/application/tags.php index 40f076fe..67202fa0 100644 --- a/docs/toolsapi/application/tags.php +++ b/docs/toolsapi/application/tags.php @@ -24,6 +24,7 @@ return [ // 模块初始化 'module_init' => [ 'app\\common\\behavior\\Common', + 'app\\common\\behavior\\RateLimitBehavior', // 全局限流排队(2026-06-25新增) ], // 插件开始 'addon_begin' => [