在多个页面中通过骨架屏优化加载状态。
在章节、礼物代付详情、阅读和搜索结果页面,用骨架屏替换传统加载指示器,以提升数据获取过程中的用户体验。 更新骨架屏样式,使加载状态更加美观。 实现章节和配置信息的缓存策略,以优化性能并减少冷启动问题。
This commit is contained in:
@@ -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>
|
||||
|
||||
<!-- 书籍信息卡 -->
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
/* ===== 书籍信息卡 ===== */
|
||||
|
||||
@@ -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}}">
|
||||
|
||||
@@ -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 卡片 */
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 结果列表 -->
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
|
||||
@@ -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` 或测试脚本,按场景组织用例。
|
||||
|
||||
234
scripts/test/miniapp/test_article_preview_speed.py
Normal file
234
scripts/test/miniapp/test_article_preview_speed.py
Normal 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())
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
61
soul-api/internal/cache/cache.go
vendored
61
soul-api/internal/cache/cache.go
vendored
@@ -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 同时调用)
|
||||
|
||||
@@ -159,6 +159,7 @@ func AdminChaptersAction(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
InvalidateChaptersByPartCache()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
@@ -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, "%", "\\%")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
32
开发文档/测试报告-文章阅读与界面预览响应速度-20260318.md
Normal file
32
开发文档/测试报告-文章阅读与界面预览响应速度-20260318.md
Normal 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
|
||||
Reference in New Issue
Block a user