Files
soul-yongping/开发文档/8、部署/章节阅读付费标准流程设计.md
2026-03-07 22:58:43 +08:00

16 KiB
Raw Permalink Blame History

章节阅读与付费标准流程设计

目标:规范阅读/付费流程,规避 bug追踪阅读状态是否读完为后续数据分析/推荐提供基础。


一、核心问题与设计目标

当前存在的风险点

  1. 权限判断时机不统一:有些地方用本地缓存、有些用接口,可能不一致
  2. 登录前后状态切换:未登录→登录、登录后免费列表变化,状态同步复杂
  3. 阅读进度无追踪:只知道"是否打开过",不知"是否读完"、"读到哪"
  4. 付费前重复校验支付前、登录后、initSection 多次请求 check-purchased
  5. 异常降级策略不统一:网络失败时有些保守、有些用缓存,可能误解锁

设计目标

  • 唯一权威数据源章节权限以服务端为准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天

  1. 新增 accessState 字段和状态机逻辑
  2. 统一 determineAccessState 方法
  3. 修改 onLoadonLoginSuccessonPaymentSuccess 按标准流程
  4. 统一异常处理和重试机制

阶段二阅读进度追踪2-3天

  1. 创建 reading_progress 表(迁移脚本)
  2. 实现 initReadingTrackerwatchScrollProgresscheckCompletion
  3. 实现本地存储 + 定期上报
  4. 实现断点续读

阶段三测试与优化1-2天

  1. 单元测试:各状态流转、异常降级
  2. 集成测试:登录、支付、阅读完整流程
  3. 边界测试:网络超时、服务端错误、并发操作
  4. 性能优化:节流、防抖、缓存策略

阶段四:数据分析接入(可选)

  1. 对接统计平台(如微信小程序数据助手、神策、诸葛等)
  2. 配置关键指标看板:购买转化率、阅读完成率、平均阅读时长
  3. 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 - 数据表迁移

以上为完整设计方案,建议先实施阶段一、二,验证效果后再进行阶段三、四。