From 46f94a9c810dba272b5bde82a40b28f717d37eb4 Mon Sep 17 00:00:00 2001 From: Alex-larget <33240357+Alex-larget@users.noreply.github.com> Date: Wed, 18 Mar 2026 12:56:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9C=A8=E5=A4=9A=E4=B8=AA=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E4=B8=AD=E9=80=9A=E8=BF=87=E9=AA=A8=E6=9E=B6=E5=B1=8F=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=8A=A0=E8=BD=BD=E7=8A=B6=E6=80=81=E3=80=82=20?= =?UTF-8?q?=E5=9C=A8=E7=AB=A0=E8=8A=82=E3=80=81=E7=A4=BC=E7=89=A9=E4=BB=A3?= =?UTF-8?q?=E4=BB=98=E8=AF=A6=E6=83=85=E3=80=81=E9=98=85=E8=AF=BB=E5=92=8C?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E7=BB=93=E6=9E=9C=E9=A1=B5=E9=9D=A2=EF=BC=8C?= =?UTF-8?q?=E7=94=A8=E9=AA=A8=E6=9E=B6=E5=B1=8F=E6=9B=BF=E6=8D=A2=E4=BC=A0?= =?UTF-8?q?=E7=BB=9F=E5=8A=A0=E8=BD=BD=E6=8C=87=E7=A4=BA=E5=99=A8=EF=BC=8C?= =?UTF-8?q?=E4=BB=A5=E6=8F=90=E5=8D=87=E6=95=B0=E6=8D=AE=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E8=BF=87=E7=A8=8B=E4=B8=AD=E7=9A=84=E7=94=A8=E6=88=B7=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E3=80=82=20=E6=9B=B4=E6=96=B0=E9=AA=A8=E6=9E=B6?= =?UTF-8?q?=E5=B1=8F=E6=A0=B7=E5=BC=8F=EF=BC=8C=E4=BD=BF=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=9B=B4=E5=8A=A0=E7=BE=8E=E8=A7=82=E3=80=82?= =?UTF-8?q?=20=E5=AE=9E=E7=8E=B0=E7=AB=A0=E8=8A=82=E5=92=8C=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=BF=A1=E6=81=AF=E7=9A=84=E7=BC=93=E5=AD=98=E7=AD=96?= =?UTF-8?q?=E7=95=A5=EF=BC=8C=E4=BB=A5=E4=BC=98=E5=8C=96=E6=80=A7=E8=83=BD?= =?UTF-8?q?=E5=B9=B6=E5=87=8F=E5=B0=91=E5=86=B7=E5=90=AF=E5=8A=A8=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniprogram/pages/chapters/chapters.wxml | 19 +- miniprogram/pages/chapters/chapters.wxss | 81 ++++-- miniprogram/pages/gift-pay/detail.wxml | 17 +- miniprogram/pages/gift-pay/detail.wxss | 103 ++++++-- miniprogram/pages/index/index.wxml | 13 - miniprogram/pages/match/match.wxml | 4 +- miniprogram/pages/match/match.wxss | 12 +- miniprogram/pages/read/read.wxml | 31 ++- miniprogram/pages/read/read.wxss | 47 +++- miniprogram/pages/search/search.wxml | 13 +- miniprogram/pages/search/search.wxss | 59 +++-- scripts/test/miniapp/README.md | 12 + .../miniapp/test_article_preview_speed.py | 234 ++++++++++++++++++ soul-api/cmd/server/main.go | 4 +- soul-api/go.mod | 38 +-- soul-api/go.sum | 42 ++++ soul-api/internal/cache/cache.go | 61 ++++- soul-api/internal/handler/admin_chapters.go | 1 + soul-api/internal/handler/book.go | 118 ++++++++- soul-api/internal/handler/db.go | 28 ++- soul-api/internal/handler/db_book.go | 8 + soul-api/internal/router/router.go | 2 + ...试报告-文章阅读与界面预览响应速度-20260318.md | 32 +++ 23 files changed, 841 insertions(+), 138 deletions(-) create mode 100644 scripts/test/miniapp/test_article_preview_speed.py create mode 100644 开发文档/测试报告-文章阅读与界面预览响应速度-20260318.md diff --git a/miniprogram/pages/chapters/chapters.wxml b/miniprogram/pages/chapters/chapters.wxml index 1f2c9eba..5d584051 100644 --- a/miniprogram/pages/chapters/chapters.wxml +++ b/miniprogram/pages/chapters/chapters.wxml @@ -17,10 +17,21 @@ - - - - 加载目录中... + + + + + + + + + + + + + + + diff --git a/miniprogram/pages/chapters/chapters.wxss b/miniprogram/pages/chapters/chapters.wxss index 7fa6b02d..c7e3f11f 100644 --- a/miniprogram/pages/chapters/chapters.wxss +++ b/miniprogram/pages/chapters/chapters.wxss @@ -75,32 +75,75 @@ width: 100%; } -/* ===== 目录加载中 ===== */ -.parts-loading { +/* ===== 目录骨架屏 ===== */ +.parts-skeleton { + padding: 32rpx; +} + +.skeleton-book-card { + display: flex; + align-items: center; + gap: 24rpx; + padding: 32rpx; + background: linear-gradient(135deg, #1c1c1e 0%, #2c2c2e 100%); + border-radius: 32rpx; + margin-bottom: 32rpx; +} + +.skeleton-book-icon { + width: 96rpx; + height: 96rpx; + border-radius: 24rpx; + background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; + flex-shrink: 0; +} + +.skeleton-book-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 16rpx; +} + +.skeleton-line { + height: 32rpx; + background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; + border-radius: 8rpx; +} + +.skeleton-title { width: 70%; } +.skeleton-subtitle { width: 50%; } + +.skeleton-count { + width: 80rpx; + height: 64rpx; + background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; + border-radius: 16rpx; +} + +.skeleton-part-list { display: flex; flex-direction: column; - align-items: center; - justify-content: center; - padding: 120rpx 0; gap: 24rpx; } -.parts-loading-spinner { - width: 64rpx; - height: 64rpx; - border: 6rpx solid rgba(255, 255, 255, 0.1); - border-top-color: #00CED1; - border-radius: 50%; - animation: parts-spin 0.8s linear infinite; +.skeleton-part-item .skeleton-part-header { + height: 100rpx; + background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; + border-radius: 16rpx; } -.parts-loading-text { - font-size: 28rpx; - color: rgba(255, 255, 255, 0.5); -} - -@keyframes parts-spin { - to { transform: rotate(360deg); } +@keyframes skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } } /* ===== 书籍信息卡 ===== */ diff --git a/miniprogram/pages/gift-pay/detail.wxml b/miniprogram/pages/gift-pay/detail.wxml index 02dcce2a..244559b7 100644 --- a/miniprogram/pages/gift-pay/detail.wxml +++ b/miniprogram/pages/gift-pay/detail.wxml @@ -14,9 +14,20 @@ - - - 加载中... + + + + + + + + + + + + + + diff --git a/miniprogram/pages/gift-pay/detail.wxss b/miniprogram/pages/gift-pay/detail.wxss index 7134086b..9ce4aa5c 100644 --- a/miniprogram/pages/gift-pay/detail.wxss +++ b/miniprogram/pages/gift-pay/detail.wxss @@ -56,32 +56,97 @@ padding: 24rpx 24rpx 200rpx; } -/* 加载 */ -.loading-box { +/* 骨架屏 */ +.skeleton-wrap { + padding: 24rpx 0; +} + +.skeleton-hero { + background: rgba(24, 24, 27, 0.8); + border-radius: 32rpx; + padding: 40rpx; + margin-bottom: 32rpx; +} + +.skeleton-hero-badge { + width: 120rpx; + height: 40rpx; + background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; + border-radius: 8rpx; + margin-bottom: 24rpx; +} + +.skeleton-hero-title { + width: 80%; + height: 48rpx; + background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; + border-radius: 8rpx; + margin-bottom: 16rpx; +} + +.skeleton-hero-desc { + width: 60%; + height: 32rpx; + background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; + border-radius: 8rpx; + margin-bottom: 32rpx; +} + +.skeleton-hero-amount { + width: 200rpx; + height: 64rpx; + background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; + border-radius: 12rpx; +} + +.skeleton-card { + display: flex; + align-items: center; + gap: 24rpx; + padding: 32rpx; + background: rgba(24, 24, 27, 0.6); + border-radius: 24rpx; +} + +.skeleton-avatar { + width: 96rpx; + height: 96rpx; + border-radius: 50%; + background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; + flex-shrink: 0; +} + +.skeleton-info { + flex: 1; display: flex; flex-direction: column; - align-items: center; - justify-content: center; - padding: 120rpx 0; + gap: 16rpx; } -.loading-spinner { - width: 48rpx; - height: 48rpx; - border: 4rpx solid rgba(20, 184, 166, 0.2); - border-top-color: #14b8a6; - border-radius: 50%; - animation: spin 0.8s linear infinite; +.skeleton-info .skeleton-line { + height: 32rpx; + background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; + border-radius: 8rpx; } -@keyframes spin { - to { transform: rotate(360deg); } -} +.skeleton-info .skeleton-line { width: 70%; } +.skeleton-info .skeleton-line.short { width: 45%; } -.loading-text { - margin-top: 24rpx; - font-size: 28rpx; - color: rgba(255, 255, 255, 0.45); +@keyframes skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } } /* 产品 Hero 卡片 */ diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml index 39f037f5..13001d69 100644 --- a/miniprogram/pages/index/index.wxml +++ b/miniprogram/pages/index/index.wxml @@ -52,19 +52,6 @@ - - - - 阅读进度 - 已读 {{readCount}}/{{totalSections}} - - - - - - - - diff --git a/miniprogram/pages/match/match.wxml b/miniprogram/pages/match/match.wxml index a975729e..1d689120 100644 --- a/miniprogram/pages/match/match.wxml +++ b/miniprogram/pages/match/match.wxml @@ -4,9 +4,7 @@ - - ⚙️ - + 找伙伴 diff --git a/miniprogram/pages/match/match.wxss b/miniprogram/pages/match/match.wxss index 3eac33f3..7852ded2 100644 --- a/miniprogram/pages/match/match.wxss +++ b/miniprogram/pages/match/match.wxss @@ -27,15 +27,9 @@ padding: 0 32rpx; } -.nav-settings { +.nav-left-placeholder { width: 80rpx; - height: 80rpx; flex-shrink: 0; - border-radius: 50%; - background: #1c1c1e; - display: flex; - align-items: center; - justify-content: center; } .nav-title { @@ -51,10 +45,6 @@ flex-shrink: 0; } -.settings-icon { - font-size: 36rpx; -} - .nav-placeholder { width: 100%; } diff --git a/miniprogram/pages/read/read.wxml b/miniprogram/pages/read/read.wxml index 87199feb..d7e334b0 100644 --- a/miniprogram/pages/read/read.wxml +++ b/miniprogram/pages/read/read.wxml @@ -24,8 +24,26 @@ - - + + + + + + + + + + + + + + + + + + + + {{section.id}} 免费 @@ -33,15 +51,6 @@ {{section.title}} - - - - - - - - - diff --git a/miniprogram/pages/read/read.wxss b/miniprogram/pages/read/read.wxss index 009eae63..f790c17b 100644 --- a/miniprogram/pages/read/read.wxss +++ b/miniprogram/pages/read/read.wxss @@ -144,8 +144,35 @@ line-height: 1.4; } -/* ===== 加载状态 ===== */ -.loading-state { +/* ===== 骨架屏 ===== */ +.skeleton-wrap { + padding-top: 24rpx; +} + +.skeleton-header { + margin-bottom: 40rpx; +} + +.skeleton-meta { + width: 120rpx; + height: 48rpx; + background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); + background-size: 200% 100%; + animation: skeleton-loading 1.5s ease-in-out infinite; + border-radius: 32rpx; + margin-bottom: 24rpx; +} + +.skeleton-title { + width: 85%; + height: 52rpx; + background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); + background-size: 200% 100%; + animation: skeleton-loading 1.5s ease-in-out infinite; + border-radius: 8rpx; +} + +.skeleton-lines { display: flex; flex-direction: column; gap: 32rpx; @@ -164,6 +191,9 @@ .skeleton-3 { width: 65%; } .skeleton-4 { width: 85%; } .skeleton-5 { width: 70%; } +.skeleton-6 { width: 80%; } +.skeleton-7 { width: 60%; } +.skeleton-8 { width: 88%; } @keyframes skeleton-loading { 0% { background-position: 200% 0; } @@ -439,21 +469,24 @@ .action-row-inline { display: flex; + flex-wrap: nowrap; gap: 16rpx; } .action-btn-inline { - flex: 1; + flex: 1 1 0; + min-width: 0; display: flex; align-items: center; justify-content: center; gap: 8rpx; - padding: 24rpx 16rpx; + padding: 24rpx 12rpx; border-radius: 16rpx; border: none; background: transparent; line-height: normal; box-sizing: border-box; + overflow: hidden; } .action-btn-inline::after { @@ -473,12 +506,18 @@ .action-icon-small { font-size: 28rpx; + flex-shrink: 0; } .action-text-small { font-size: 24rpx; color: #ffffff; font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; } .share-tip-inline { diff --git a/miniprogram/pages/search/search.wxml b/miniprogram/pages/search/search.wxml index 6a9e4df7..6d9fb9e0 100644 --- a/miniprogram/pages/search/search.wxml +++ b/miniprogram/pages/search/search.wxml @@ -65,10 +65,15 @@ - - - - 搜索中... + + + + + + + + + diff --git a/miniprogram/pages/search/search.wxss b/miniprogram/pages/search/search.wxss index aa56b19b..1f67a348 100644 --- a/miniprogram/pages/search/search.wxss +++ b/miniprogram/pages/search/search.wxss @@ -284,30 +284,57 @@ } /* 加载状态 */ -.loading-wrap { +/* 搜索结果骨架屏 */ +.skeleton-results { + padding: 24rpx 0; +} + +.skeleton-result-item { + display: flex; + align-items: center; + gap: 24rpx; + padding: 28rpx 0; + border-bottom: 1rpx solid rgba(255,255,255,0.06); +} + +.skeleton-result-rank { + width: 56rpx; + height: 56rpx; + background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; + border-radius: 12rpx; + flex-shrink: 0; +} + +.skeleton-result-content { + flex: 1; display: flex; flex-direction: column; - align-items: center; - padding: 100rpx 0; + gap: 16rpx; } -.loading-spinner { - width: 60rpx; - height: 60rpx; - border: 4rpx solid rgba(0, 206, 209, 0.3); - border-top-color: #00CED1; - border-radius: 50%; - animation: spin 1s linear infinite; +.skeleton-result-title { + width: 85%; + height: 36rpx; + background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; + border-radius: 8rpx; } -@keyframes spin { - to { transform: rotate(360deg); } +.skeleton-result-meta { + width: 50%; + height: 28rpx; + background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; + border-radius: 8rpx; } -.loading-text { - margin-top: 24rpx; - font-size: 28rpx; - color: rgba(255,255,255,0.5); +@keyframes skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } } /* 空状态 */ diff --git a/scripts/test/miniapp/README.md b/scripts/test/miniapp/README.md index 0b79fa08..1a614756 100644 --- a/scripts/test/miniapp/README.md +++ b/scripts/test/miniapp/README.md @@ -14,6 +14,18 @@ --- +## 响应速度测试 + +`test_article_preview_speed.py`:文章阅读与界面预览 GET 接口响应速度测试。 + +```bash +SOUL_TEST_ENV=soulapi python scripts/test/miniapp/test_article_preview_speed.py +``` + +产出:控制台报表 + `开发文档/测试报告-文章阅读与界面预览响应速度-YYYYMMDD.md` + +--- + ## 用例编写 在此目录下新增 `.md` 或测试脚本,按场景组织用例。 diff --git a/scripts/test/miniapp/test_article_preview_speed.py b/scripts/test/miniapp/test_article_preview_speed.py new file mode 100644 index 00000000..4e3799ac --- /dev/null +++ b/scripts/test/miniapp/test_article_preview_speed.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +文章阅读与界面预览 GET 接口响应速度测试 + +测试范围: +- 界面预览:config、book/parts、book/all-chapters、book/chapters-by-part +- 文章阅读:book/chapter/:id、book/chapter/by-mid/:mid + +用法: + SOUL_TEST_ENV=soulapi python scripts/test/miniapp/test_article_preview_speed.py + SOUL_TEST_ENV=soulapi python -m scripts.test.miniapp.test_article_preview_speed + +产出:控制台报表 + 开发文档/测试报告-文章阅读与界面预览响应速度-YYYYMMDD.md +""" +import json +import sys +import time +from pathlib import Path + +import requests + +# 加载测试配置 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from config import API_BASE, ENV_LABEL, get_env_banner + +# 每接口请求次数(取平均) +ROUNDS = 5 +TIMEOUT = 30 + + +def measure_get(url: str, desc: str) -> dict: + """对 GET 请求测速,返回 {ok, status_code, times_ms, avg_ms, min_ms, max_ms, error}""" + times_ms = [] + last_error = None + last_status = None + for _ in range(ROUNDS): + t0 = time.perf_counter() + try: + r = requests.get(url, timeout=TIMEOUT) + last_status = r.status_code + elapsed = (time.perf_counter() - t0) * 1000 + times_ms.append(elapsed) + if r.status_code != 200: + last_error = f"HTTP {r.status_code}" + except requests.RequestException as e: + last_error = str(e) + times_ms.append(-1) + if not times_ms: + return {"ok": False, "error": last_error or "无响应", "status_code": last_status} + valid = [t for t in times_ms if t >= 0] + return { + "ok": len(valid) == ROUNDS and (last_status or 200) == 200, + "status_code": last_status, + "times_ms": times_ms, + "avg_ms": sum(valid) / len(valid) if valid else 0, + "min_ms": min(valid) if valid else 0, + "max_ms": max(valid) if valid else 0, + "error": last_error, + } + + +def main(): + print(get_env_banner()) + base = API_BASE.rstrip("/") + + # 1. 先拉取 parts 和 all-chapters,获取 partId、id、mid + parts_url = f"{base}/api/miniprogram/book/parts" + all_chapters_url = f"{base}/api/miniprogram/book/all-chapters" + + parts_data = None + all_chapters_data = None + try: + r = requests.get(parts_url, timeout=TIMEOUT) + if r.status_code == 200: + parts_data = r.json() + except Exception: + pass + try: + r = requests.get(all_chapters_url, timeout=TIMEOUT) + if r.status_code == 200: + all_chapters_data = r.json() + except Exception: + pass + + part_id = None + chapter_id = None + chapter_mid = None + if parts_data and parts_data.get("success"): + parts = parts_data.get("parts") or [] + fixed = parts_data.get("fixedSections") or [] + if parts: + part_id = parts[0].get("id") + if fixed: + chapter_mid = fixed[0].get("mid") + chapter_id = fixed[0].get("id") + if (not chapter_id or not chapter_mid) and all_chapters_data and all_chapters_data.get("success"): + arr = all_chapters_data.get("data") or all_chapters_data.get("chapters") or [] + if arr: + first = arr[0] if isinstance(arr[0], dict) else {} + chapter_id = chapter_id or first.get("id") + chapter_mid = chapter_mid or first.get("mid") + if not part_id and parts_data and parts_data.get("success"): + parts = parts_data.get("parts") or [] + if parts: + part_id = parts[0].get("id") + + # 2. 定义测试用例(仅 GET) + cases = [ + ("界面预览-配置", f"{base}/api/miniprogram/config", "GET /api/miniprogram/config"), + ("界面预览-目录", f"{base}/api/miniprogram/book/parts", "GET /api/miniprogram/book/parts"), + ("界面预览-全书章节", f"{base}/api/miniprogram/book/all-chapters", "GET /api/miniprogram/book/all-chapters"), + ] + if part_id: + cases.append( + ( + "界面预览-篇章内章节", + f"{base}/api/miniprogram/book/chapters-by-part?partId={part_id}", + f"GET /api/miniprogram/book/chapters-by-part?partId={part_id}", + ) + ) + if chapter_id: + cases.append( + ( + "文章阅读-按id", + f"{base}/api/miniprogram/book/chapter/{chapter_id}", + f"GET /api/miniprogram/book/chapter/:id", + ) + ) + if chapter_mid: + cases.append( + ( + "文章阅读-按mid", + f"{base}/api/miniprogram/book/chapter/by-mid/{chapter_mid}", + f"GET /api/miniprogram/book/chapter/by-mid/:mid", + ) + ) + + # 3. 执行测速 + results = [] + for name, url, api_desc in cases: + print(f"\n测速: {name} ({api_desc})") + res = measure_get(url, name) + res["name"] = name + res["api"] = api_desc + res["url"] = url + results.append(res) + if res["ok"]: + print(f" [OK] avg={res['avg_ms']:.0f}ms (min={res['min_ms']:.0f}, max={res['max_ms']:.0f})") + else: + print(f" [FAIL] {res.get('error', res.get('status_code', '?'))}") + + # 4. 生成报表 + from datetime import datetime + + date_str = datetime.now().strftime("%Y-%m-%d %H:%M") + date_file = datetime.now().strftime("%Y%m%d") + + lines = [ + "# 文章阅读与界面预览 GET 接口响应速度测试报告", + "", + f"**测试时间**: {date_str}", + f"**测试环境**: {ENV_LABEL} ({API_BASE})", + f"**每接口请求次数**: {ROUNDS}", + "", + "## 一、测试范围", + "", + "| 分类 | 接口 | 说明 |", + "|------|------|------|", + "| 界面预览 | GET /api/miniprogram/config | 配置(价格、功能开关等) |", + "| 界面预览 | GET /api/miniprogram/book/parts | 目录-篇章列表 |", + "| 界面预览 | GET /api/miniprogram/book/all-chapters | 全书章节列表 |", + "| 界面预览 | GET /api/miniprogram/book/chapters-by-part | 篇章内章节列表 |", + "| 文章阅读 | GET /api/miniprogram/book/chapter/:id | 按业务 id 获取章节内容 |", + "| 文章阅读 | GET /api/miniprogram/book/chapter/by-mid/:mid | 按 mid 获取章节内容 |", + "", + "## 二、响应速度结果", + "", + "| 接口 | 状态 | 平均(ms) | 最小(ms) | 最大(ms) |", + "|------|------|----------|----------|----------|", + ] + + for r in results: + status = "OK" if r["ok"] else "FAIL" + avg = f"{r['avg_ms']:.0f}" if r["ok"] else "-" + min_ms = f"{r['min_ms']:.0f}" if r["ok"] else "-" + max_ms = f"{r['max_ms']:.0f}" if r["ok"] else "-" + if not r["ok"]: + err = r.get("error", "") or f"HTTP {r.get('status_code', '?')}" + avg = err[:20] if err else "-" + lines.append(f"| {r['api']} | {status} | {avg} | {min_ms} | {max_ms} |") + + # 汇总 + ok_count = sum(1 for r in results if r["ok"]) + total_count = len(results) + if ok_count == total_count: + avg_all = sum(r["avg_ms"] for r in results) / total_count + lines.extend([ + "", + "## 三、汇总", + "", + f"- 通过: {ok_count}/{total_count}", + f"- 全部接口平均响应: {avg_all:.0f}ms", + "", + ]) + else: + lines.extend([ + "", + "## 三、汇总", + "", + f"- 通过: {ok_count}/{total_count}", + f"- 失败: {total_count - ok_count} 个接口", + "", + ]) + + report_content = "\n".join(lines) + + # 5. 输出到控制台 + print("\n" + "=" * 60) + print(report_content) + print("=" * 60) + + # 6. 写入文件(项目根/开发文档) + report_dir = Path(__file__).resolve().parent.parent.parent.parent / "开发文档" + report_dir.mkdir(parents=True, exist_ok=True) + report_path = report_dir / f"测试报告-文章阅读与界面预览响应速度-{date_file}.md" + report_path.write_text(report_content, encoding="utf-8") + print(f"\n报表已保存: {report_path}") + + return 0 if ok_count == total_count else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/soul-api/cmd/server/main.go b/soul-api/cmd/server/main.go index a2bca901..042cb17c 100644 --- a/soul-api/cmd/server/main.go +++ b/soul-api/cmd/server/main.go @@ -46,11 +46,13 @@ func main() { Handler: r, } - // 预热 all-chapters、book/parts 缓存,避免首请求冷启动 502 + // 预热 Redis 缓存,避免首请求冷启动 502 go func() { time.Sleep(2 * time.Second) // 等 DB 完全就绪 handler.WarmAllChaptersCache() handler.WarmBookPartsCache() + handler.WarmConfigCache() + handler.WarmLatestChaptersCache() }() go func() { diff --git a/soul-api/go.mod b/soul-api/go.mod index 6d5436f0..44b9692e 100644 --- a/soul-api/go.mod +++ b/soul-api/go.mod @@ -6,7 +6,7 @@ require ( github.com/ArtisanCloud/PowerLibs/v3 v3.3.2 github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38 github.com/gin-contrib/cors v1.7.2 - github.com/gin-gonic/gin v1.10.0 + github.com/gin-gonic/gin v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/joho/godotenv v1.5.1 github.com/unrolled/secure v1.17.0 @@ -16,46 +16,56 @@ require ( gorm.io/gorm v1.25.12 ) -require github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect +require ( + github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/gin-contrib/gzip v1.2.5 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.55.0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/tools v0.40.0 // indirect +) require ( - github.com/bytedance/sonic v1.11.6 // indirect - github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/bytedance/sonic v1.14.1 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect - github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/gabriel-vasile/mimetype v1.4.3 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/go-playground/validator/v10 v10.28.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/redis/go-redis/v9 v9.17.3 github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.12 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect - golang.org/x/arch v0.8.0 // indirect + golang.org/x/arch v0.22.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/protobuf v1.34.1 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/soul-api/go.sum b/soul-api/go.sum index bf017f0b..163bddac 100644 --- a/soul-api/go.sum +++ b/soul-api/go.sum @@ -8,16 +8,24 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -27,12 +35,20 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= +github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI= +github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -45,10 +61,16 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -65,6 +87,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= @@ -83,10 +107,16 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= +github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4= github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= @@ -108,6 +138,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -129,10 +161,16 @@ go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= @@ -141,8 +179,12 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/soul-api/internal/cache/cache.go b/soul-api/internal/cache/cache.go index c894658b..87c5308e 100644 --- a/soul-api/internal/cache/cache.go +++ b/soul-api/internal/cache/cache.go @@ -17,6 +17,28 @@ const defaultTimeout = 2 * time.Second // KeyBookParts 目录接口缓存 key,后台更新章节/内容时需 Del const KeyBookParts = "soul:book:parts" +// KeyAllChapters 全书章节列表,default 与 excludeFixed 两种 +func KeyAllChapters(cacheKey string) string { + if cacheKey == "excludeFixed" { + return "soul:book:all-chapters:excludeFixed" + } + return "soul:book:all-chapters" +} + +// KeyChaptersByPart 篇章内章节,格式 soul:book:chapters-by-part:{partId} +func KeyChaptersByPart(partId string) string { + return "soul:book:chapters-by-part:" + partId +} + +// KeyChaptersByPartPattern 用于批量删除 chapters-by-part 缓存 +const KeyChaptersByPartPattern = "soul:book:chapters-by-part:*" + +// KeyBookLatestChapters 最新更新章节 +const KeyBookLatestChapters = "soul:book:latest-chapters" + +// KeyFreeChapterIDs 免费章节 ID 列表(JSON 数组) +const KeyFreeChapterIDs = "soul:config:free-chapters" + // KeyBookHot 热门章节,格式 soul:book:hot:{limit} func KeyBookHot(limit int) string { return "soul:book:hot:" + fmt.Sprint(limit) } const KeyBookRecommended = "soul:book:recommended" @@ -81,12 +103,47 @@ func Del(ctx context.Context, key string) { } } +// DelPattern 按模式删除 key(如 soul:book:chapters-by-part:*),用于批量失效 +func DelPattern(ctx context.Context, pattern string) { + client := redis.Client() + if client == nil { + return + } + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithTimeout(ctx, defaultTimeout*2) + defer cancel() + keys, err := client.Keys(ctx, pattern).Result() + if err != nil || len(keys) == 0 { + return + } + if err := client.Del(ctx, keys...).Err(); err != nil { + log.Printf("cache.DelPattern %s: %v (非致命)", pattern, err) + } +} + // BookPartsTTL 目录接口缓存 TTL,后台更新时主动 Del,此为兜底时长 const BookPartsTTL = 10 * time.Minute -// InvalidateBookParts 后台更新章节/内容时调用,使目录接口缓存失效 +// AllChaptersTTL 全书章节列表 TTL +const AllChaptersTTL = 10 * time.Minute + +// ChaptersByPartTTL 篇章内章节 TTL +const ChaptersByPartTTL = 10 * time.Minute + +// FreeChapterIDsTTL 免费章节配置 TTL +const FreeChapterIDsTTL = 5 * time.Minute + +// InvalidateBookParts 后台更新章节/内容时调用,使目录、章节列表等缓存失效 func InvalidateBookParts() { - Del(context.Background(), KeyBookParts) + ctx := context.Background() + Del(ctx, KeyBookParts) + Del(ctx, KeyAllChapters("default")) + Del(ctx, KeyAllChapters("excludeFixed")) + Del(ctx, KeyBookLatestChapters) + Del(ctx, KeyFreeChapterIDs) + DelPattern(ctx, KeyChaptersByPartPattern) } // InvalidateBookCache 使热门、推荐、统计等书籍相关缓存失效(与 InvalidateBookParts 同时调用) diff --git a/soul-api/internal/handler/admin_chapters.go b/soul-api/internal/handler/admin_chapters.go index ddd2236b..0ce433c0 100644 --- a/soul-api/internal/handler/admin_chapters.go +++ b/soul-api/internal/handler/admin_chapters.go @@ -159,6 +159,7 @@ func AdminChaptersAction(c *gin.Context) { } } cache.InvalidateBookParts() + InvalidateChaptersByPartCache() cache.InvalidateBookCache() c.JSON(http.StatusOK, gin.H{"success": true}) } diff --git a/soul-api/internal/handler/book.go b/soul-api/internal/handler/book.go index 5dbe4823..ef651808 100644 --- a/soul-api/internal/handler/book.go +++ b/soul-api/internal/handler/book.go @@ -94,7 +94,27 @@ var bookPartsCache struct { const bookPartsCacheTTL = 30 * time.Second -// WarmAllChaptersCache 启动时预热缓存,避免首请求冷启动 502 +// chaptersByPartCache 篇章内章节列表内存缓存,30 秒 TTL +type chaptersByPartEntry struct { + data []model.Chapter + expires time.Time +} + +var chaptersByPartCache struct { + mu sync.RWMutex + entries map[string]*chaptersByPartEntry +} + +const chaptersByPartCacheTTL = 30 * time.Second + +// InvalidateChaptersByPartCache 后台更新章节时调用,使 chapters-by-part 内存缓存失效 +func InvalidateChaptersByPartCache() { + chaptersByPartCache.mu.Lock() + chaptersByPartCache.entries = nil + chaptersByPartCache.mu.Unlock() +} + +// WarmAllChaptersCache 启动时预热缓存(Redis+内存),避免首请求冷启动 502 func WarmAllChaptersCache() { db := database.DB() q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols) @@ -112,6 +132,7 @@ func WarmAllChaptersCache() { list[i].Price = &z } } + cache.Set(context.Background(), cache.KeyAllChapters("default"), list, cache.AllChaptersTTL) allChaptersCache.mu.Lock() allChaptersCache.data = list allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL) @@ -205,12 +226,19 @@ func WarmBookPartsCache() { // 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id // 免费判断:system_config.free_chapters / chapter_config.freeChapters 优先于 chapters.is_free // 支持 excludeFixed=1:排除序言、尾声、附录(目录页固定模块,不参与中间篇章) -// 带 30 秒内存缓存,管理端更新后最多 30 秒生效 +// 缓存优先级:Redis(10min)> 内存(30s)> DB;后台更新时失效 func BookAllChapters(c *gin.Context) { cacheKey := "default" if c.Query("excludeFixed") == "1" { cacheKey = "excludeFixed" } + // 1. 优先 Redis + var list []model.Chapter + if cache.Get(context.Background(), cache.KeyAllChapters(cacheKey), &list) && len(list) > 0 { + c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) + return + } + // 2. 内存缓存 allChaptersCache.mu.RLock() if allChaptersCache.key == cacheKey && time.Now().Before(allChaptersCache.expires) && len(allChaptersCache.data) > 0 { data := allChaptersCache.data @@ -220,6 +248,7 @@ func BookAllChapters(c *gin.Context) { } allChaptersCache.mu.RUnlock() + // 3. DB 查询 db := database.DB() q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols) if cacheKey == "excludeFixed" { @@ -227,7 +256,6 @@ func BookAllChapters(c *gin.Context) { q = q.Where("part_title NOT LIKE ?", "%"+p+"%") } } - var list []model.Chapter if err := q.Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&list).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}}) return @@ -243,6 +271,8 @@ func BookAllChapters(c *gin.Context) { } } + // 回填 Redis + 内存 + cache.Set(context.Background(), cache.KeyAllChapters(cacheKey), list, cache.AllChaptersTTL) allChaptersCache.mu.Lock() allChaptersCache.data = list allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL) @@ -311,14 +341,33 @@ func BookParts(c *gin.Context) { } // BookChaptersByPart GET /api/miniprogram/book/chapters-by-part?partId=xxx 按篇章返回章节列表(含 mid,供阅读页 by-mid 请求) +// 缓存优先级:Redis(10min)> 内存(30s)> DB;后台更新时失效 func BookChaptersByPart(c *gin.Context) { partId := c.Query("partId") if partId == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 partId"}) return } - db := database.DB() + // 1. 优先 Redis var list []model.Chapter + if cache.Get(context.Background(), cache.KeyChaptersByPart(partId), &list) && len(list) > 0 { + c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) + return + } + // 2. 内存缓存 + chaptersByPartCache.mu.RLock() + if chaptersByPartCache.entries != nil { + if e, ok := chaptersByPartCache.entries[partId]; ok && time.Now().Before(e.expires) { + list := e.data + chaptersByPartCache.mu.RUnlock() + c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) + return + } + } + chaptersByPartCache.mu.RUnlock() + + // 3. DB 查询 + db := database.DB() if err := db.Model(&model.Chapter{}).Select(allChaptersSelectCols). Where("part_id = ?", partId). Order("COALESCE(sort_order, 999999) ASC, id ASC"). @@ -336,6 +385,16 @@ func BookChaptersByPart(c *gin.Context) { list[i].Price = &z } } + + // 回填 Redis + 内存 + cache.Set(context.Background(), cache.KeyChaptersByPart(partId), list, cache.ChaptersByPartTTL) + chaptersByPartCache.mu.Lock() + if chaptersByPartCache.entries == nil { + chaptersByPartCache.entries = make(map[string]*chaptersByPartEntry) + } + chaptersByPartCache.entries[partId] = &chaptersByPartEntry{data: list, expires: time.Now().Add(chaptersByPartCacheTTL)} + chaptersByPartCache.mu.Unlock() + c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) } @@ -357,8 +416,16 @@ func BookChapterByMID(c *gin.Context) { } // getFreeChapterIDs 从 system_config 读取免费章节 ID 列表(free_chapters 或 chapter_config.freeChapters) +// Redis 缓存 5min,后台更新时失效 func getFreeChapterIDs(db *gorm.DB) map[string]bool { - ids := make(map[string]bool) + var ids map[string]bool + if cache.Get(context.Background(), cache.KeyFreeChapterIDs, &ids) { + if ids == nil { + return make(map[string]bool) + } + return ids + } + ids = make(map[string]bool) for _, key := range []string{"free_chapters", "chapter_config"} { var row model.SystemConfig if err := db.Where("config_key = ?", key).First(&row).Error; err != nil { @@ -388,6 +455,7 @@ func getFreeChapterIDs(db *gorm.DB) map[string]bool { } } } + cache.Set(context.Background(), cache.KeyFreeChapterIDs, ids, cache.FreeChapterIDsTTL) return ids } @@ -773,13 +841,18 @@ func BookRecommended(c *gin.Context) { } // BookLatestChapters GET /api/book/latest-chapters 最新更新(按 updated_at 降序,排除序言/尾声/附录) +// Redis 缓存 5min,首页「最新更新」主接口 func BookLatestChapters(c *gin.Context) { + var list []model.Chapter + if cache.Get(context.Background(), cache.KeyBookLatestChapters, &list) && len(list) > 0 { + c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) + return + } db := database.DB() q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols) for _, p := range excludeParts { q = q.Where("part_title NOT LIKE ?", "%"+p+"%") } - var list []model.Chapter if err := q.Order("updated_at DESC, id ASC").Limit(20).Find(&list).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}}) return @@ -799,9 +872,42 @@ func BookLatestChapters(c *gin.Context) { list[i].Price = &z } } + cache.Set(context.Background(), cache.KeyBookLatestChapters, list, cache.BookRelatedTTL) c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) } +// WarmLatestChaptersCache 启动时预热最新章节 Redis 缓存(首页主接口) +func WarmLatestChaptersCache() { + var list []model.Chapter + if cache.Get(context.Background(), cache.KeyBookLatestChapters, &list) && len(list) > 0 { + return + } + db := database.DB() + q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols) + for _, p := range excludeParts { + q = q.Where("part_title NOT LIKE ?", "%"+p+"%") + } + if err := q.Order("updated_at DESC, id ASC").Limit(20).Find(&list).Error; err != nil { + return + } + sort.Slice(list, func(i, j int) bool { + if !list[i].UpdatedAt.Equal(list[j].UpdatedAt) { + return list[i].UpdatedAt.After(list[j].UpdatedAt) + } + return naturalLessSectionID(list[i].ID, list[j].ID) + }) + freeIDs := getFreeChapterIDs(db) + for i := range list { + if freeIDs[list[i].ID] { + t := true + z := float64(0) + list[i].IsFree = &t + list[i].Price = &z + } + } + cache.Set(context.Background(), cache.KeyBookLatestChapters, list, cache.BookRelatedTTL) +} + func escapeLikeBook(s string) string { s = strings.ReplaceAll(s, "\\", "\\\\") s = strings.ReplaceAll(s, "%", "\\%") diff --git a/soul-api/internal/handler/db.go b/soul-api/internal/handler/db.go index b48f68ef..9ff6d7b5 100644 --- a/soul-api/internal/handler/db.go +++ b/soul-api/internal/handler/db.go @@ -17,14 +17,8 @@ import ( "github.com/gin-gonic/gin" ) -// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐) -// Redis 缓存 10min,配置变更时失效 -func GetPublicDBConfig(c *gin.Context) { - var cached map[string]interface{} - if cache.Get(context.Background(), cache.KeyConfigMiniprogram, &cached) && len(cached) > 0 { - c.JSON(http.StatusOK, cached) - return - } +// buildMiniprogramConfig 从 DB 构建小程序配置,供 GetPublicDBConfig 与 WarmConfigCache 复用 +func buildMiniprogramConfig() gin.H { defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9} defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true} apiDomain := "https://soulapi.quwanzhi.com" @@ -144,10 +138,28 @@ func GetPublicDBConfig(c *gin.Context) { mp["auditMode"] = false } } + return out +} + +// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐) +// Redis 缓存 10min,配置变更时失效 +func GetPublicDBConfig(c *gin.Context) { + var cached map[string]interface{} + if cache.Get(context.Background(), cache.KeyConfigMiniprogram, &cached) && len(cached) > 0 { + c.JSON(http.StatusOK, cached) + return + } + out := buildMiniprogramConfig() cache.Set(context.Background(), cache.KeyConfigMiniprogram, out, cache.ConfigTTL) c.JSON(http.StatusOK, out) } +// WarmConfigCache 启动时预热 config 缓存,避免首请求冷启动 +func WarmConfigCache() { + out := buildMiniprogramConfig() + cache.Set(context.Background(), cache.KeyConfigMiniprogram, out, cache.ConfigTTL) +} + // DBConfigGet GET /api/db/config(管理端鉴权后同路径由 db 组处理时用) func DBConfigGet(c *gin.Context) { key := c.Query("key") diff --git a/soul-api/internal/handler/db_book.go b/soul-api/internal/handler/db_book.go index e08cae24..8dd34aa3 100644 --- a/soul-api/internal/handler/db_book.go +++ b/soul-api/internal/handler/db_book.go @@ -442,6 +442,7 @@ func DBBookAction(c *gin.Context) { switch body.Action { case "sync": cache.InvalidateBookParts() + InvalidateChaptersByPartCache() cache.InvalidateBookCache() c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步完成(Gin 无文件源时可从 DB 已存在数据视为已同步)"}) return @@ -495,6 +496,7 @@ func DBBookAction(c *gin.Context) { imported++ } cache.InvalidateBookParts() + InvalidateChaptersByPartCache() cache.InvalidateBookCache() c.JSON(http.StatusOK, gin.H{"success": true, "message": "导入完成", "imported": imported, "failed": failed}) return @@ -560,6 +562,7 @@ func DBBookAction(c *gin.Context) { _ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", it.ID).Updates(up).Error } cache.InvalidateBookParts() + InvalidateChaptersByPartCache() cache.InvalidateBookCache() }() return @@ -576,6 +579,7 @@ func DBBookAction(c *gin.Context) { } } cache.InvalidateBookParts() + InvalidateChaptersByPartCache() cache.InvalidateBookCache() }() return @@ -601,6 +605,7 @@ func DBBookAction(c *gin.Context) { return } cache.InvalidateBookParts() + InvalidateChaptersByPartCache() cache.InvalidateBookCache() c.JSON(http.StatusOK, gin.H{"success": true, "message": "已移动", "count": len(body.SectionIds)}) return @@ -710,6 +715,7 @@ func DBBookAction(c *gin.Context) { } cache.InvalidateChapterContent(ch.MID) cache.InvalidateBookParts() + InvalidateChaptersByPartCache() cache.InvalidateBookCache() c.JSON(http.StatusOK, gin.H{"success": true}) return @@ -725,6 +731,7 @@ func DBBookAction(c *gin.Context) { } cache.InvalidateChapterContentByID(body.ID) cache.InvalidateBookParts() + InvalidateChaptersByPartCache() cache.InvalidateBookCache() c.JSON(http.StatusOK, gin.H{"success": true}) return @@ -772,6 +779,7 @@ func DBBookDelete(c *gin.Context) { return } cache.InvalidateBookParts() + InvalidateChaptersByPartCache() cache.InvalidateBookCache() c.JSON(http.StatusOK, gin.H{"success": true}) } diff --git a/soul-api/internal/router/router.go b/soul-api/internal/router/router.go index dbdc0efd..a3a738cf 100644 --- a/soul-api/internal/router/router.go +++ b/soul-api/internal/router/router.go @@ -10,6 +10,7 @@ import ( "soul-api/internal/redis" "github.com/gin-contrib/cors" + "github.com/gin-contrib/gzip" "github.com/gin-gonic/gin" ) @@ -22,6 +23,7 @@ func Setup(cfg *config.Config) *gin.Engine { _ = r.SetTrustedProxies(cfg.TrustedProxies) r.Use(middleware.Secure()) + r.Use(gzip.Gzip(gzip.DefaultCompression)) r.Use(cors.New(cors.Config{ AllowOrigins: cfg.CORSOrigins, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, diff --git a/开发文档/测试报告-文章阅读与界面预览响应速度-20260318.md b/开发文档/测试报告-文章阅读与界面预览响应速度-20260318.md new file mode 100644 index 00000000..cc6a925d --- /dev/null +++ b/开发文档/测试报告-文章阅读与界面预览响应速度-20260318.md @@ -0,0 +1,32 @@ +# 文章阅读与界面预览 GET 接口响应速度测试报告 + +**测试时间**: 2026-03-18 12:43 +**测试环境**: 正式 (https://soulapi.quwanzhi.com) +**每接口请求次数**: 5 + +## 一、测试范围 + +| 分类 | 接口 | 说明 | +|------|------|------| +| 界面预览 | GET /api/miniprogram/config | 配置(价格、功能开关等) | +| 界面预览 | GET /api/miniprogram/book/parts | 目录-篇章列表 | +| 界面预览 | GET /api/miniprogram/book/all-chapters | 全书章节列表 | +| 界面预览 | GET /api/miniprogram/book/chapters-by-part | 篇章内章节列表 | +| 文章阅读 | GET /api/miniprogram/book/chapter/:id | 按业务 id 获取章节内容 | +| 文章阅读 | GET /api/miniprogram/book/chapter/by-mid/:mid | 按 mid 获取章节内容 | + +## 二、响应速度结果 + +| 接口 | 状态 | 平均(ms) | 最小(ms) | 最大(ms) | +|------|------|----------|----------|----------| +| GET /api/miniprogram/config | OK | 390 | 378 | 406 | +| GET /api/miniprogram/book/parts | OK | 396 | 387 | 403 | +| GET /api/miniprogram/book/all-chapters | OK | 390 | 376 | 407 | +| GET /api/miniprogram/book/chapters-by-part?partId=part-2026-daily | OK | 420 | 416 | 424 | +| GET /api/miniprogram/book/chapter/:id | OK | 420 | 401 | 425 | +| GET /api/miniprogram/book/chapter/by-mid/:mid | OK | 424 | 419 | 431 | + +## 三、汇总 + +- 通过: 6/6 +- 全部接口平均响应: 406ms