16 KiB
16 KiB
章节阅读与付费标准流程设计
目标:规范阅读/付费流程,规避 bug,追踪阅读状态(是否读完),为后续数据分析/推荐提供基础。
一、核心问题与设计目标
当前存在的风险点
- 权限判断时机不统一:有些地方用本地缓存、有些用接口,可能不一致
- 登录前后状态切换:未登录→登录、登录后免费列表变化,状态同步复杂
- 阅读进度无追踪:只知道"是否打开过",不知"是否读完"、"读到哪"
- 付费前重复校验:支付前、登录后、initSection 多次请求 check-purchased
- 异常降级策略不统一:网络失败时有些保守、有些用缓存,可能误解锁
设计目标
- 唯一权威数据源:章节权限以服务端为准(users + orders 表)
- 标准状态机:章节状态、用户状态明确定义,流转有迹可循
- 阅读进度追踪:记录滚动进度、阅读时长、是否读完(≥90% 或到底部)
- 统一异常处理:网络失败、超时、服务端错误统一降级策略(保守+重试)
- 流程可回溯:关键节点打日志,便于排查 bug 和数据分析
二、标准状态机设计
2.1 章节权限状态(ChapterAccessState)
| 状态 | 说明 | 前端展示 |
|---|---|---|
unknown |
初始/加载中,尚未确定权限 | loading 骨架屏 |
free |
免费章节,无需登录/购买 | 全文 + 已读标记 |
locked_not_login |
付费章节 + 用户未登录 | 预览 + 登录按钮 |
locked_not_purchased |
付费章节 + 已登录但未购买 | 预览 + 购买按钮 |
unlocked_purchased |
付费章节 + 已购买(单章/全书) | 全文 + 已读标记 |
error |
权限校验失败(网络/服务端错误) | 预览 + 重试按钮 |
2.2 阅读进度状态(ReadingProgressState)
{
sectionId: '1.2',
status: 'reading' | 'completed' | 'abandoned', // 阅读中 | 已完成 | 已放弃(30天未回)
progress: 75, // 滚动进度百分比 0-100
duration: 360, // 累计阅读时长(秒)
lastPosition: 1200, // 上次滚动位置(px)
completedAt: null, // 读完时间戳(达到90%+停留3s 或滑到底部)
firstOpenAt: 1738560000,// 首次打开时间戳
lastOpenAt: 1738563600 // 最后打开时间戳
}
2.3 状态流转图
进入阅读页
↓
[unknown] 加载中
↓
拉取最新免费列表 + 用户登录状态
↓
├─ 免费章节 → [free] → 全文展示 → 记录阅读进度
├─ 未登录 → [locked_not_login] → 预览 + 登录按钮
│ ↓ 登录成功
│ ├─ 章节已免费 → [free]
│ ├─ 已购买 → [unlocked_purchased]
│ └─ 未购买 → [locked_not_purchased]
├─ 已登录未购买 → [locked_not_purchased] → 预览 + 购买按钮
│ ↓ 支付成功
│ └─ [unlocked_purchased] → 全文展示
└─ 已登录已购买 → [unlocked_purchased] → 全文展示 → 记录阅读进度
网络/服务端错误 → [error] → 保守展示预览 + 重试按钮
三、标准流程与接口调用顺序
3.1 进入章节页标准流程
async onLoad(options) {
const { id, ref } = options
// 1. 初始化状态
this.setState({ accessState: 'unknown', loading: true })
// 2. 处理推荐码(异步不阻塞)
if (ref) this.handleReferralCode(ref)
// 3. 【关键】拉取最新配置(免费列表、价格等)- 串行等待
await this.fetchLatestConfig()
// 4. 【关键】确定章节权限状态 - 串行等待
const accessState = await this.determineAccessState(id)
// 5. 加载章节内容(全文或预览)
await this.loadChapterContent(id, accessState)
// 6. 若有权限则初始化阅读追踪
if (['free', 'unlocked_purchased'].includes(accessState)) {
this.initReadingTracker(id)
}
// 7. 加载上下章导航
this.loadNavigation(id)
this.setState({ loading: false })
}
3.2 determineAccessState 权限判断标准
async determineAccessState(sectionId) {
try {
// 1. 检查是否免费(以服务端最新配置为准)
if (this.isFreeChapter(sectionId)) {
return 'free'
}
// 2. 检查是否登录
const userId = app.globalData.userInfo?.id
if (!userId) {
return 'locked_not_login'
}
// 3. 【权威接口】请求服务端校验是否已购买
const res = await app.request(
`/api/user/check-purchased?userId=${userId}&type=section&productId=${sectionId}`,
{ timeout: 5000 }
)
if (res.success && res.data?.isPurchased) {
// 同步更新本地缓存(仅作展示用,不作权限依据)
this.syncLocalPurchaseCache(sectionId, res.data)
return 'unlocked_purchased'
}
return 'locked_not_purchased'
} catch (error) {
console.error('[Access] 权限判断失败:', error)
// 网络/服务端错误 → 保守策略:视为无权限 + 可重试
return 'error'
}
}
3.3 登录后重新校验标准流程
async onLoginSuccess() {
wx.showLoading({ title: '更新状态中...' })
try {
// 1. 刷新用户购买列表(全局状态)
await this.refreshUserPurchaseStatus()
// 2. 重新拉取免费列表(可能刚改免费)
await this.fetchLatestConfig()
// 3. 重新判断当前章节权限
const newAccessState = await this.determineAccessState(this.data.sectionId)
// 4. 更新状态并刷新内容
this.setState({
accessState: newAccessState,
isLoggedIn: true
})
// 5. 若已解锁则初始化阅读追踪
if (['free', 'unlocked_purchased'].includes(newAccessState)) {
await this.loadChapterContent(this.data.sectionId, newAccessState)
this.initReadingTracker(this.data.sectionId)
}
wx.hideLoading()
wx.showToast({ title: '登录成功', icon: 'success' })
} catch (e) {
wx.hideLoading()
wx.showToast({ title: '状态更新失败,请重试', icon: 'none' })
}
}
3.4 支付成功后刷新标准流程
async onPaymentSuccess() {
wx.showLoading({ title: '确认购买中...' })
try {
// 1. 等待服务端处理支付回调(1-2秒)
await this.sleep(2000)
// 2. 刷新用户购买状态(从 orders 表拉取最新)
await this.refreshUserPurchaseStatus()
// 3. 重新判断当前章节权限(应为 unlocked_purchased)
const newAccessState = await this.determineAccessState(this.data.sectionId)
if (newAccessState !== 'unlocked_purchased') {
// 支付成功但权限未生效 → 可能回调延迟,再重试一次
await this.sleep(1000)
newAccessState = await this.determineAccessState(this.data.sectionId)
}
// 4. 更新状态并重新加载全文
this.setState({ accessState: newAccessState })
await this.loadChapterContent(this.data.sectionId, newAccessState)
// 5. 初始化阅读追踪
this.initReadingTracker(this.data.sectionId)
wx.hideLoading()
wx.showToast({ title: '购买成功', icon: 'success' })
} catch (e) {
wx.hideLoading()
wx.showModal({
title: '提示',
content: '购买成功,但内容加载失败,请返回重新进入',
showCancel: false
})
}
}
四、阅读进度追踪方案
4.1 数据结构(本地 + 服务端)
本地存储(实时更新,用于断点续读):
wx.setStorageSync('reading_progress', {
'1.2': { progress: 75, duration: 360, lastPosition: 1200, lastOpenAt: xxx },
'2.1': { progress: 30, duration: 120, lastPosition: 500, lastOpenAt: xxx }
})
服务端表(定期上报,用于数据分析):
CREATE TABLE reading_progress (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id VARCHAR(50) NOT NULL,
section_id VARCHAR(20) NOT NULL,
progress INT DEFAULT 0, -- 阅读进度 0-100
duration INT DEFAULT 0, -- 累计时长(秒)
status ENUM('reading', 'completed', 'abandoned') DEFAULT 'reading',
completed_at DATETIME NULL, -- 读完时间
first_open_at DATETIME NOT NULL,
last_open_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY idx_user_section (user_id, section_id),
INDEX idx_user_status (user_id, status),
INDEX idx_completed (completed_at)
);
4.2 追踪逻辑
// 初始化阅读追踪器
initReadingTracker(sectionId) {
const tracker = {
sectionId,
startTime: Date.now(),
lastScrollTime: Date.now(),
totalDuration: 0,
maxProgress: 0,
isCompleted: false,
scrollTimer: null
}
this.readingTracker = tracker
// 恢复上次阅读位置
this.restoreLastPosition(sectionId)
// 监听滚动事件(节流)
this.watchScrollProgress()
// 定期上报进度(每30秒)
this.startProgressReport()
}
// 监听滚动进度(节流 500ms)
watchScrollProgress() {
let scrollTimer = null
wx.onPageScroll((e) => {
if (scrollTimer) clearTimeout(scrollTimer)
scrollTimer = setTimeout(() => {
const { scrollTop, scrollHeight, clientHeight } = this.getScrollInfo()
const progress = Math.min(100, Math.round((scrollTop / (scrollHeight - clientHeight)) * 100))
// 更新最大进度
if (progress > this.readingTracker.maxProgress) {
this.readingTracker.maxProgress = progress
this.saveProgressLocal(progress, scrollTop)
}
// 判断是否读完(≥90% 且停留3秒)
if (progress >= 90 && !this.readingTracker.isCompleted) {
this.checkCompletion(progress)
}
}, 500)
})
}
// 判断是否读完
async checkCompletion(progress) {
// 停留3秒后标记为已读完
await this.sleep(3000)
if (progress >= 90 && !this.readingTracker.isCompleted) {
this.readingTracker.isCompleted = true
this.readingTracker.completedAt = Date.now()
// 立即上报完成状态
await this.reportCompletion()
// 触发埋点/数据分析
this.trackEvent('chapter_completed', {
sectionId: this.data.sectionId,
duration: this.readingTracker.totalDuration
})
}
}
// 定期上报进度(每30秒,页面隐藏/卸载时也上报)
startProgressReport() {
this.reportInterval = setInterval(() => {
this.reportProgressToServer()
}, 30000)
// 页面隐藏/卸载时立即上报
wx.onHide(() => this.reportProgressToServer())
wx.onUnload(() => this.reportProgressToServer())
}
// 上报进度到服务端
async reportProgressToServer() {
if (!this.readingTracker) return
const now = Date.now()
const duration = Math.round((now - this.readingTracker.lastScrollTime) / 1000)
this.readingTracker.totalDuration += duration
this.readingTracker.lastScrollTime = now
try {
await app.request('/api/user/reading-progress', {
method: 'POST',
data: {
userId: app.globalData.userInfo?.id,
sectionId: this.readingTracker.sectionId,
progress: this.readingTracker.maxProgress,
duration: this.readingTracker.totalDuration,
status: this.readingTracker.isCompleted ? 'completed' : 'reading'
}
})
} catch (e) {
console.warn('[Progress] 上报失败,下次重试')
}
}
4.3 断点续读
// 恢复上次阅读位置
restoreLastPosition(sectionId) {
const progressData = wx.getStorageSync('reading_progress') || {}
const lastProgress = progressData[sectionId]
if (lastProgress?.lastPosition) {
wx.pageScrollTo({
scrollTop: lastProgress.lastPosition,
duration: 300
})
wx.showToast({
title: `已恢复到 ${lastProgress.progress}%`,
icon: 'none',
duration: 2000
})
}
}
五、异常处理与降级策略
5.1 统一异常处理原则
| 异常类型 | 降级策略 | 用户提示 |
|---|---|---|
| 网络超时(>5s) | 保守策略:视为无权限,展示预览 + 重试按钮 | "网络连接超时,请重试" |
| 服务端 500 | 同上 | "服务暂时不可用,请稍后重试" |
| 权限接口返回 error | 同上 | "无法确认权限,请重试" |
| 内容接口失败 | 尝试本地缓存 → 失败则重试3次 → 仍失败则提示 | "内容加载失败,已尝试 {n} 次" |
| 支付成功但权限未生效 | 延迟1秒重试一次 → 仍失败则提示联系客服 | "购买成功,正在确认..." |
5.2 重试机制
async requestWithRetry(url, options, maxRetries = 3) {
let lastError = null
for (let i = 0; i < maxRetries; i++) {
try {
const res = await app.request(url, { ...options, timeout: 5000 })
return res
} catch (e) {
lastError = e
console.warn(`[Retry] 第 ${i+1} 次请求失败:`, url, e.message)
if (i < maxRetries - 1) {
await this.sleep(1000 * (i + 1)) // 指数退避
}
}
}
throw lastError
}
六、日志与埋点规范
6.1 关键节点日志
// 进入章节
console.log('[Chapter] 进入章节', { sectionId, accessState, userId, timestamp })
// 权限判断
console.log('[Access] 权限判断', { sectionId, isFree, isLoggedIn, isPurchased, result: accessState })
// 登录成功
console.log('[Login] 登录成功', { userId, beforeState, afterState, timestamp })
// 支付成功
console.log('[Payment] 支付成功', { userId, productType, productId, amount, orderNo, timestamp })
// 阅读完成
console.log('[Reading] 阅读完成', { sectionId, duration, progress, timestamp })
// 异常
console.error('[Error] 异常', { type, message, stack, context })
6.2 数据埋点(可选,接入统计平台)
// 章节打开
trackEvent('chapter_open', { sectionId, accessState, source })
// 章节解锁(登录/支付)
trackEvent('chapter_unlocked', { sectionId, unlockMethod: 'login' | 'purchase' })
// 阅读完成
trackEvent('chapter_completed', { sectionId, duration, fromProgress })
// 购买转化
trackEvent('purchase_conversion', { productType, productId, amount, referralCode })
七、实施步骤
阶段一:重构权限判断(1-2天)
- 新增
accessState字段和状态机逻辑 - 统一
determineAccessState方法 - 修改
onLoad、onLoginSuccess、onPaymentSuccess按标准流程 - 统一异常处理和重试机制
阶段二:阅读进度追踪(2-3天)
- 创建
reading_progress表(迁移脚本) - 实现
initReadingTracker、watchScrollProgress、checkCompletion - 实现本地存储 + 定期上报
- 实现断点续读
阶段三:测试与优化(1-2天)
- 单元测试:各状态流转、异常降级
- 集成测试:登录、支付、阅读完整流程
- 边界测试:网络超时、服务端错误、并发操作
- 性能优化:节流、防抖、缓存策略
阶段四:数据分析接入(可选)
- 对接统计平台(如微信小程序数据助手、神策、诸葛等)
- 配置关键指标看板:购买转化率、阅读完成率、平均阅读时长
- A/B 测试:不同付费墙文案、价格策略
八、预期收益
- bug 减少 80%+:权限判断统一、异常处理标准化
- 用户体验提升:断点续读、进度可视化、明确的状态反馈
- 数据驱动决策:阅读完成率、购买转化漏斗分析、章节热度排行
- 可扩展性:状态机设计便于未来增加"试读 N 分钟"、"好友助力解锁"等玩法
附录:核心代码示例
完整实现代码见配套文件:
miniprogram/utils/chapterAccessManager.js- 权限管理器miniprogram/utils/readingTracker.js- 阅读追踪器app/api/user/reading-progress/route.ts- 进度上报接口scripts/create_reading_progress_table.sql- 数据表迁移
以上为完整设计方案,建议先实施阶段一、二,验证效果后再进行阶段三、四。