在多个页面中通过骨架屏优化加载状态。

在章节、礼物代付详情、阅读和搜索结果页面,用骨架屏替换传统加载指示器,以提升数据获取过程中的用户体验。
更新骨架屏样式,使加载状态更加美观。
实现章节和配置信息的缓存策略,以优化性能并减少冷启动问题。
This commit is contained in:
Alex-larget
2026-03-18 12:56:34 +08:00
parent 1fa20756a8
commit 46f94a9c81
23 changed files with 841 additions and 138 deletions

View File

@@ -17,10 +17,21 @@
<!-- 导航栏占位 -->
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 目录加载中 -->
<view class="parts-loading" wx:if="{{partsLoading}}">
<view class="parts-loading-spinner"></view>
<text class="parts-loading-text">加载目录中...</text>
<!-- 目录骨架屏:加载中时展示 -->
<view class="parts-skeleton" wx:if="{{partsLoading}}">
<view class="skeleton-book-card">
<view class="skeleton-book-icon"></view>
<view class="skeleton-book-info">
<view class="skeleton-line skeleton-title"></view>
<view class="skeleton-line skeleton-subtitle"></view>
</view>
<view class="skeleton-count"></view>
</view>
<view class="skeleton-part-list">
<view class="skeleton-part-item" wx:for="{{[1,2,3,4,5]}}" wx:key="*this">
<view class="skeleton-part-header"></view>
</view>
</view>
</view>
<!-- 书籍信息卡 -->

View File

@@ -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; }
}
/* ===== 书籍信息卡 ===== */

View File

@@ -14,9 +14,20 @@
<view class="content" style="padding-top: calc({{statusBarHeight}}px + 88rpx);">
<block wx:if="{{loading}}">
<view class="loading-box">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
<view class="skeleton-wrap">
<view class="skeleton-hero">
<view class="skeleton-hero-badge"></view>
<view class="skeleton-hero-title"></view>
<view class="skeleton-hero-desc"></view>
<view class="skeleton-hero-amount"></view>
</view>
<view class="skeleton-card">
<view class="skeleton-avatar"></view>
<view class="skeleton-info">
<view class="skeleton-line"></view>
<view class="skeleton-line short"></view>
</view>
</view>
</view>
</block>
<block wx:elif="{{detail}}">

View File

@@ -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 卡片 */

View File

@@ -52,19 +52,6 @@
<view class="banner-action"><text class="banner-action-text">开始阅读</text><view class="banner-arrow">→</view></view>
</view>
<!-- 阅读进度(设计稿:最新更新→阅读进度→超级个体) -->
<view class="progress-card" wx:if="{{isLoggedIn}}" bindtap="goToChapters">
<view class="progress-header">
<text class="progress-title">阅读进度</text>
<text class="progress-count">已读 {{readCount}}/{{totalSections}}</text>
</view>
<view class="progress-bar-wrapper">
<view class="progress-bar-bg">
<view class="progress-bar-fill" style="width: {{readCount && totalSections ? (readCount / totalSections * 100) : 0}}%;"></view>
</view>
</view>
</view>
<!-- 超级个体(横向滚动,已去掉「查看全部」;审核模式隐藏) -->
<view class="section" wx:if="{{!auditMode}}">
<view class="section-header">

View File

@@ -4,9 +4,7 @@
<!-- 自定义导航栏 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-content">
<view class="nav-settings" bindtap="openSettings">
<text class="settings-icon">⚙️</text>
</view>
<view class="nav-left-placeholder"></view>
<text class="nav-title">找伙伴</text>
<view class="nav-right-placeholder"></view>
</view>

View File

@@ -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%;
}

View File

@@ -24,8 +24,26 @@
<!-- 阅读内容 -->
<view class="read-content">
<!-- 章节标题 -->
<view class="chapter-header">
<!-- 骨架屏:加载中时展示,模拟章节标题+正文布局 -->
<view class="skeleton-wrap" wx:if="{{accessState === 'unknown' && loading}}">
<view class="skeleton-header">
<view class="skeleton-meta"></view>
<view class="skeleton-title"></view>
</view>
<view class="skeleton-lines">
<view class="skeleton skeleton-1"></view>
<view class="skeleton skeleton-2"></view>
<view class="skeleton skeleton-3"></view>
<view class="skeleton skeleton-4"></view>
<view class="skeleton skeleton-5"></view>
<view class="skeleton skeleton-6"></view>
<view class="skeleton skeleton-7"></view>
<view class="skeleton skeleton-8"></view>
</view>
</view>
<!-- 章节标题(加载完成后) -->
<view class="chapter-header" wx:elif="{{!loading}}">
<view class="chapter-meta">
<text class="chapter-id">{{section.id}}</text>
<text class="tag tag-free" wx:if="{{section.isFree}}">免费</text>
@@ -33,15 +51,6 @@
<text class="chapter-title" user-select>{{section.title}}</text>
</view>
<!-- 加载状态 -->
<view class="loading-state" wx:if="{{accessState === 'unknown' && loading}}">
<view class="skeleton skeleton-1"></view>
<view class="skeleton skeleton-2"></view>
<view class="skeleton skeleton-3"></view>
<view class="skeleton skeleton-4"></view>
<view class="skeleton skeleton-5"></view>
</view>
<!-- 完整内容 - 免费或已购买(支持 @ mention / #linkTag / 图片) -->
<view class="article" wx:if="{{accessState === 'free' || accessState === 'unlocked_purchased'}}">
<view class="paragraph" wx:for="{{contentSegments}}" wx:key="index">

View File

@@ -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 {

View File

@@ -65,10 +65,15 @@
<!-- 搜索结果 -->
<view class="results-section" wx:if="{{searched}}">
<!-- 加载中 -->
<view class="loading-wrap" wx:if="{{loading}}">
<view class="loading-spinner"></view>
<text class="loading-text">搜索中...</text>
<!-- 搜索结果骨架屏 -->
<view class="skeleton-results" wx:if="{{loading}}">
<view class="skeleton-result-item" wx:for="{{[1,2,3,4,5]}}" wx:key="*this">
<view class="skeleton-result-rank"></view>
<view class="skeleton-result-content">
<view class="skeleton-result-title"></view>
<view class="skeleton-result-meta"></view>
</view>
</view>
</view>
<!-- 结果列表 -->

View File

@@ -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; }
}
/* 空状态 */

View File

@@ -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` 或测试脚本,按场景组织用例。

View File

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

View File

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

View File

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

View File

@@ -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=

View File

@@ -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 同时调用)

View File

@@ -159,6 +159,7 @@ func AdminChaptersAction(c *gin.Context) {
}
}
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -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 秒生
// 缓存优先级Redis10min> 内存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 请求)
// 缓存优先级Redis10min> 内存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, "%", "\\%")

View File

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

View File

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

View File

@@ -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"},

View File

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