优化小程序支付流程,新增订单插入逻辑,确保支付成功后更新订单状态并处理佣金分配。同时,重构阅读页面,增强权限管理和阅读追踪功能,提升用户体验。

This commit is contained in:
乘风
2026-02-04 21:36:26 +08:00
parent 25fd3190b2
commit 67ef87095f
48 changed files with 9619 additions and 1218 deletions

View File

@@ -1,9 +1,18 @@
/**
* Soul创业派对 - 阅读页
* Soul创业派对 - 阅读页(标准流程版)
* 开发: 卡若
* 技术支持: 存客宝
*
* 更新: 2026-02-04
* - 引入权限管理器chapterAccessManager统一权限判断
* - 引入阅读追踪器readingTracker记录阅读进度、时长、是否读完
* - 使用状态机accessState规范权限流转
* - 异常统一保守处理,避免误解锁
*/
import accessManager from '../../utils/chapterAccessManager'
import readingTracker from '../../utils/readingTracker'
const app = getApp()
Page({
@@ -25,10 +34,14 @@ Page({
previewParagraphs: [],
loading: true,
// 【新增】权限状态机(替代 canAccess
// unknown: 加载中 | free: 免费 | locked_not_login: 未登录 | locked_not_purchased: 未购买 | unlocked_purchased: 已购买 | error: 错误
accessState: 'unknown',
// 用户状态
isLoggedIn: false,
hasFullBook: false,
canAccess: false,
canAccess: false, // 保留兼容性,从 accessState 派生
purchasedCount: 0,
// 阅读进度
@@ -55,89 +68,143 @@ Page({
freeIds: ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3']
},
onLoad(options) {
async onLoad(options) {
const { id, ref } = options
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
navBarHeight: app.globalData.navBarHeight,
sectionId: id
sectionId: id,
loading: true,
accessState: 'unknown'
})
// 处理推荐码绑定
// 处理推荐码绑定(异步不阻塞)
if (ref) {
console.log('[Read] 检测到推荐码:', ref)
wx.setStorageSync('referral_code', ref)
app.handleReferralCode({ query: { ref } })
}
// 加载免费章节配置
this.loadFreeChaptersConfig()
this.initSection(id)
try {
// 【标准流程】1. 拉取最新配置(免费列表、价格)
const config = await accessManager.fetchLatestConfig()
this.setData({
freeIds: config.freeChapters,
sectionPrice: config.prices.section,
fullBookPrice: config.prices.fullbook
})
// 【标准流程】2. 确定权限状态
const accessState = await accessManager.determineAccessState(id, config.freeChapters)
const canAccess = accessManager.canAccessFullContent(accessState)
this.setData({
accessState,
canAccess,
isLoggedIn: !!app.globalData.userInfo?.id,
showPaywall: !canAccess
})
// 【标准流程】3. 加载内容
await this.loadContent(id, accessState)
// 【标准流程】4. 如果有权限,初始化阅读追踪
if (canAccess) {
readingTracker.init(id)
}
// 5. 加载导航
this.loadNavigation(id)
} catch (e) {
console.error('[Read] 初始化失败:', e)
wx.showToast({ title: '加载失败,请重试', icon: 'none' })
this.setData({ accessState: 'error', loading: false })
} finally {
this.setData({ loading: false })
}
},
// 从后端加载免费章节配置
async loadFreeChaptersConfig() {
try {
const res = await app.request('/api/db/config')
if (res.success && res.freeChapters) {
this.setData({ freeIds: res.freeChapters })
console.log('[Read] 加载免费章节配置:', res.freeChapters)
}
} catch (e) {
console.log('[Read] 使用默认免费章节配置')
}
},
onPageScroll(e) {
// 计算阅读进度
// 只在有权限时追踪阅读进度
if (!accessManager.canAccessFullContent(this.data.accessState)) {
return
}
// 获取滚动信息并更新追踪器
const query = wx.createSelectorQuery()
query.select('.page').boundingClientRect()
query.selectViewport().scrollOffset()
query.exec((res) => {
if (res[0]) {
const scrollTop = e.scrollTop
const pageHeight = res[0].height - this.data.statusBarHeight - 200
const progress = pageHeight > 0 ? Math.min((scrollTop / pageHeight) * 100, 100) : 0
if (res[0] && res[1]) {
const scrollInfo = {
scrollTop: res[1].scrollTop,
scrollHeight: res[0].height,
clientHeight: res[1].height
}
// 计算进度条显示(用于 UI
const totalScrollable = scrollInfo.scrollHeight - scrollInfo.clientHeight
const progress = totalScrollable > 0
? Math.min((scrollInfo.scrollTop / totalScrollable) * 100, 100)
: 0
this.setData({ readingProgress: progress })
// 更新阅读追踪器(记录最大进度、判断是否读完)
readingTracker.updateProgress(scrollInfo)
}
})
},
// 初始化章节
async initSection(id) {
this.setData({ loading: true })
// 【重构】加载章节内容(专注于内容加载,权限判断已在 onLoad 中由 accessManager 完成)
async loadContent(id, accessState) {
try {
// 模拟获取章节数据
const section = this.getSectionInfo(id)
const { isLoggedIn, hasFullBook, purchasedSections } = app.globalData
this.setData({ section })
const isFree = this.data.freeIds.includes(id)
const isPurchased = hasFullBook || (purchasedSections && purchasedSections.includes(id))
const canAccess = isFree || isPurchased
const purchasedCount = purchasedSections?.length || 0
this.setData({
section,
isLoggedIn,
hasFullBook,
canAccess,
purchasedCount,
showPaywall: !canAccess
})
// 加载内容
await this.loadContent(id)
// 获取上一篇/下一篇
this.loadNavigation(id)
// 从 API 获取内容
const res = await app.request(`/api/book/chapter/${id}`)
if (res && res.content) {
const lines = res.content.split('\n').filter(line => line.trim())
const previewCount = Math.ceil(lines.length * 0.2)
this.setData({
content: res.content,
contentParagraphs: lines,
previewParagraphs: lines.slice(0, previewCount),
partTitle: res.partTitle || '',
chapterTitle: res.chapterTitle || ''
})
// 如果有权限,标记为已读
if (accessManager.canAccessFullContent(accessState)) {
app.markSectionAsRead(id)
}
}
} catch (e) {
console.error('初始化章节失败:', e)
wx.showToast({ title: '加载失败', icon: 'none' })
} finally {
this.setData({ loading: false })
console.error('[Read] 加载内容失败:', e)
// 尝试从本地缓存加载
const cacheKey = `chapter_${id}`
try {
const cached = wx.getStorageSync(cacheKey)
if (cached && cached.content) {
const lines = cached.content.split('\n').filter(line => line.trim())
const previewCount = Math.ceil(lines.length * 0.2)
this.setData({
content: cached.content,
contentParagraphs: lines,
previewParagraphs: lines.slice(0, previewCount)
})
console.log('[Read] 从本地缓存加载成功')
}
} catch (cacheErr) {
console.warn('[Read] 本地缓存也失败:', cacheErr)
}
throw e
}
},
@@ -421,21 +488,24 @@ Page({
this.setData({ showLoginModal: false })
},
// 微信登录
// 从服务端刷新购买状态,避免登录后误用旧数据导致误解锁
// 【重构】微信登录(标准流程)
async handleWechatLogin() {
try {
const result = await app.login()
if (result) {
this.setData({ showLoginModal: false })
this.initSection(this.data.sectionId)
wx.showToast({ title: '登录成功', icon: 'success' })
}
if (!result) return
this.setData({ showLoginModal: false })
await this.onLoginSuccess()
wx.showToast({ title: '登录成功', icon: 'success' })
} catch (e) {
console.error('[Read] 登录失败:', e)
wx.showToast({ title: '登录失败', icon: 'none' })
}
},
// 手机号登录
// 【重构】手机号登录(标准流程)
async handlePhoneLogin(e) {
if (!e.detail.code) {
return this.handleWechatLogin()
@@ -443,16 +513,59 @@ Page({
try {
const result = await app.loginWithPhone(e.detail.code)
if (result) {
this.setData({ showLoginModal: false })
this.initSection(this.data.sectionId)
wx.showToast({ title: '登录成功', icon: 'success' })
}
if (!result) return
this.setData({ showLoginModal: false })
await this.onLoginSuccess()
wx.showToast({ title: '登录成功', icon: 'success' })
} catch (e) {
console.error('[Read] 手机号登录失败:', e)
wx.showToast({ title: '登录失败', icon: 'none' })
}
},
// 【新增】登录成功后的标准处理流程
async onLoginSuccess() {
wx.showLoading({ title: '更新状态中...', mask: true })
try {
// 1. 刷新用户购买状态(从 orders 表拉取最新)
await accessManager.refreshUserPurchaseStatus()
// 2. 重新拉取免费列表(极端情况:刚登录时当前章节可能改免费了)
const config = await accessManager.fetchLatestConfig()
this.setData({ freeIds: config.freeChapters })
// 3. 重新判断当前章节权限
const newAccessState = await accessManager.determineAccessState(
this.data.sectionId,
config.freeChapters
)
const canAccess = accessManager.canAccessFullContent(newAccessState)
this.setData({
accessState: newAccessState,
canAccess,
isLoggedIn: true,
showPaywall: !canAccess
})
// 4. 如果已解锁,重新加载内容并初始化阅读追踪
if (canAccess) {
await this.loadContent(this.data.sectionId, newAccessState)
readingTracker.init(this.data.sectionId)
}
wx.hideLoading()
} catch (e) {
wx.hideLoading()
console.error('[Read] 登录后更新状态失败:', e)
wx.showToast({ title: '状态更新失败,请重试', icon: 'none' })
}
},
// 购买章节 - 直接调起支付
async handlePurchaseSection() {
console.log('[Pay] 点击购买章节按钮')
@@ -499,18 +612,38 @@ Page({
return
}
// 检查是否已购买(避免重复购买
if (type === 'section' && sectionId) {
const purchasedSections = app.globalData.purchasedSections || []
if (purchasedSections.includes(sectionId)) {
wx.showToast({ title: '已购买过此章节', icon: 'none' })
return
// ✅ 从服务器查询是否已购买(基于 orders 表
try {
wx.showLoading({ title: '检查购买状态...', mask: true })
const userId = app.globalData.userInfo?.id
if (userId) {
const checkRes = await app.request(`/api/user/purchase-status?userId=${userId}`)
if (checkRes.success && checkRes.data) {
// 更新本地购买状态
app.globalData.hasFullBook = checkRes.data.hasFullBook
app.globalData.purchasedSections = checkRes.data.purchasedSections || []
// 检查是否已购买
if (type === 'section' && sectionId) {
if (checkRes.data.purchasedSections.includes(sectionId)) {
wx.hideLoading()
wx.showToast({ title: '已购买过此章节', icon: 'none' })
return
}
}
if (type === 'fullbook' && checkRes.data.hasFullBook) {
wx.hideLoading()
wx.showToast({ title: '已购买全书', icon: 'none' })
return
}
}
}
}
if (type === 'fullbook' && app.globalData.hasFullBook) {
wx.showToast({ title: '已购买全书', icon: 'none' })
return
} catch (e) {
console.warn('[Pay] 查询购买状态失败,继续支付流程:', e)
// 查询失败不影响支付
}
this.setData({ isPaying: true })
@@ -556,6 +689,8 @@ Page({
? '《一场Soul的创业实验》全书'
: `章节${sectionId}-${sectionTitle.length > 20 ? sectionTitle.slice(0, 20) + '...' : sectionTitle}`
// 邀请码:谁邀请了我(从落地页 ref 或 storage 带入),用于订单分销归属
const referralCode = wx.getStorageSync('referral_code') || ''
const res = await app.request('/api/miniprogram/pay', {
method: 'POST',
data: {
@@ -564,7 +699,8 @@ Page({
productId: sectionId,
amount,
description,
userId: app.globalData.userInfo?.id || ''
userId: app.globalData.userInfo?.id || '',
referralCode: referralCode || undefined
}
})
@@ -607,13 +743,10 @@ Page({
try {
await this.callWechatPay(paymentData)
// 4. 支付成功,更新本地数据
// 4. 【标准流程】支付成功后刷新权限并解锁内容
console.log('[Pay] 微信支付成功!')
this.mockPaymentSuccess(type, sectionId)
wx.showToast({ title: '购买成功', icon: 'success' })
await this.onPaymentSuccess()
// 5. 刷新页面
this.initSection(this.data.sectionId)
} catch (payErr) {
console.error('[Pay] 微信支付调起失败:', payErr)
if (payErr.errMsg && payErr.errMsg.includes('cancel')) {
@@ -648,25 +781,95 @@ Page({
}
},
// 模拟支付成功
mockPaymentSuccess(type, sectionId) {
if (type === 'fullbook') {
app.globalData.hasFullBook = true
const userInfo = app.globalData.userInfo || {}
userInfo.hasFullBook = true
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
} else if (sectionId) {
const purchasedSections = app.globalData.purchasedSections || []
if (!purchasedSections.includes(sectionId)) {
purchasedSections.push(sectionId)
app.globalData.purchasedSections = purchasedSections
// 【新增】支付成功后的标准处理流程
async onPaymentSuccess() {
wx.showLoading({ title: '确认购买中...', mask: true })
try {
// 1. 等待服务端处理支付回调1-2秒
await this.sleep(2000)
// 2. 刷新用户购买状态
await accessManager.refreshUserPurchaseStatus()
// 3. 重新判断当前章节权限(应为 unlocked_purchased
let newAccessState = await accessManager.determineAccessState(
this.data.sectionId,
this.data.freeIds
)
// 如果权限未生效,再重试一次(可能回调延迟)
if (newAccessState !== 'unlocked_purchased') {
console.log('[Pay] 权限未生效1秒后重试...')
await this.sleep(1000)
newAccessState = await accessManager.determineAccessState(
this.data.sectionId,
this.data.freeIds
)
}
const canAccess = accessManager.canAccessFullContent(newAccessState)
this.setData({
accessState: newAccessState,
canAccess,
showPaywall: !canAccess
})
// 4. 重新加载全文
await this.loadContent(this.data.sectionId, newAccessState)
// 5. 初始化阅读追踪
if (canAccess) {
readingTracker.init(this.data.sectionId)
}
wx.hideLoading()
wx.showToast({ title: '购买成功', icon: 'success' })
} catch (e) {
wx.hideLoading()
console.error('[Pay] 支付后更新失败:', e)
wx.showModal({
title: '提示',
content: '购买成功,但内容加载失败,请返回重新进入',
showCancel: false
})
}
},
// ✅ 刷新用户购买状态(从服务器获取最新数据)
async refreshUserPurchaseStatus() {
try {
const userId = app.globalData.userInfo?.id
if (!userId) {
console.warn('[Pay] 用户未登录,无法刷新购买状态')
return
}
// 调用专门的购买状态查询接口
const res = await app.request(`/api/user/purchase-status?userId=${userId}`)
if (res.success && res.data) {
// 更新全局购买状态
app.globalData.hasFullBook = res.data.hasFullBook
app.globalData.purchasedSections = res.data.purchasedSections || []
// 更新用户信息中的购买记录
const userInfo = app.globalData.userInfo || {}
userInfo.purchasedSections = purchasedSections
userInfo.hasFullBook = res.data.hasFullBook
userInfo.purchasedSections = res.data.purchasedSections || []
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
console.log('[Pay] ✅ 购买状态已刷新:', {
hasFullBook: res.data.hasFullBook,
purchasedCount: res.data.purchasedSections.length
})
}
} catch (e) {
console.error('[Pay] 刷新购买状态失败:', e)
// 刷新失败时不影响用户体验,只是记录日志
}
},
@@ -905,5 +1108,63 @@ Page({
},
// 阻止冒泡
stopPropagation() {}
stopPropagation() {},
// 【新增】页面隐藏时上报阅读进度
onHide() {
readingTracker.onPageHide()
},
// 【新增】页面卸载时清理追踪器
onUnload() {
readingTracker.cleanup()
},
// 【新增】重试加载(当 accessState 为 error 时)
async handleRetry() {
wx.showLoading({ title: '重试中...', mask: true })
try {
// 重新拉取配置
const config = await accessManager.fetchLatestConfig()
this.setData({ freeIds: config.freeChapters })
// 重新判断权限
const newAccessState = await accessManager.determineAccessState(
this.data.sectionId,
config.freeChapters
)
const canAccess = accessManager.canAccessFullContent(newAccessState)
this.setData({
accessState: newAccessState,
canAccess,
showPaywall: !canAccess
})
// 重新加载内容
await this.loadContent(this.data.sectionId, newAccessState)
// 如果有权限,初始化阅读追踪
if (canAccess) {
readingTracker.init(this.data.sectionId)
}
// 加载导航
this.loadNavigation(this.data.sectionId)
wx.hideLoading()
wx.showToast({ title: '加载成功', icon: 'success' })
} catch (e) {
wx.hideLoading()
console.error('[Read] 重试失败:', e)
wx.showToast({ title: '重试失败,请检查网络', icon: 'none' })
}
},
// 工具:延迟
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -35,7 +35,7 @@
</view>
<!-- 加载状态 -->
<view class="loading-state" wx:if="{{loading}}">
<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>
@@ -43,8 +43,8 @@
<view class="skeleton skeleton-5"></view>
</view>
<!-- 完整内容 - 有权限 -->
<view class="article" wx:if="{{!loading && canAccess}}">
<!-- 完整内容 - 免费或已购买 -->
<view class="article" wx:if="{{accessState === 'free' || accessState === 'unlocked_purchased'}}">
<view class="paragraph" wx:for="{{contentParagraphs}}" wx:key="index" wx:if="{{item}}">
{{item}}
</view>
@@ -95,8 +95,8 @@
</view>
</view>
<!-- 预览内容 + 付费墙 - 无权限 -->
<view class="article preview" wx:if="{{!loading && !canAccess}}">
<!-- 预览内容 + 付费墙 - 未登录 -->
<view class="article preview" wx:if="{{accessState === 'locked_not_login'}}">
<view class="paragraph" wx:for="{{previewParagraphs}}" wx:key="index" wx:if="{{item}}">
{{item}}
</view>
@@ -104,46 +104,18 @@
<!-- 渐变遮罩 -->
<view class="fade-mask"></view>
<!-- 付费墙 -->
<view class="paywall" wx:if="{{showPaywall}}">
<!-- 付费墙 - 未登录 -->
<view class="paywall">
<view class="paywall-icon">🔒</view>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">
已阅读20%{{isLoggedIn ? '购买后继续阅读' : '登录并购买后继续阅读'}}
</text>
<text class="paywall-title">登录后继续阅读</text>
<text class="paywall-desc">已阅读20%,登录后查看完整内容</text>
<!-- 未登录时显示登录按钮 -->
<view class="login-prompt" wx:if="{{!isLoggedIn}}">
<view class="login-btn" bindtap="showLoginModal">
<text class="login-btn-text">请先登录</text>
</view>
<view class="login-btn" bindtap="showLoginModal">
<text class="login-btn-text">立即登录</text>
</view>
<!-- 已登录显示购买选项 -->
<view class="purchase-options" wx:else>
<!-- 购买本章 - 直接调起支付 -->
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥{{section.price}}</text>
</view>
<!-- 解锁全书 - 只有购买超过3章才显示 -->
<view class="purchase-btn purchase-fullbook" bindtap="handlePurchaseFullBook" wx:if="{{purchasedCount >= 3}}">
<view class="btn-left">
<text class="btn-sparkle">✨</text>
<text class="btn-label">解锁全部 {{totalSections}} 章</text>
</view>
<view class="btn-right">
<text class="btn-price">¥{{fullBookPrice}}</text>
<text class="btn-discount">省82%</text>
</view>
</view>
</view>
<text class="paywall-tip">分享给好友一起学习</text>
</view>
<!-- 章节导航 - 付费内容也显示 -->
<!-- 章节导航 -->
<view class="chapter-nav chapter-nav-locked">
<view class="nav-buttons">
<view
@@ -173,6 +145,97 @@
</view>
</view>
</view>
<!-- 预览内容 + 付费墙 - 已登录未购买 -->
<view class="article preview" wx:if="{{accessState === 'locked_not_purchased'}}">
<view class="paragraph" wx:for="{{previewParagraphs}}" wx:key="index" wx:if="{{item}}">
{{item}}
</view>
<!-- 渐变遮罩 -->
<view class="fade-mask"></view>
<!-- 付费墙 - 已登录未购买 -->
<view class="paywall">
<view class="paywall-icon">🔒</view>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已阅读20%,购买后继续阅读</text>
<!-- 购买选项 -->
<view class="purchase-options">
<!-- 购买本章 - 直接调起支付 -->
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥{{section.price}}</text>
</view>
<!-- 解锁全书 - 只有购买超过3章才显示 -->
<view class="purchase-btn purchase-fullbook" bindtap="handlePurchaseFullBook" wx:if="{{purchasedCount >= 3}}">
<view class="btn-left">
<text class="btn-sparkle">✨</text>
<text class="btn-label">解锁全部 {{totalSections}} 章</text>
</view>
<view class="btn-right">
<text class="btn-price">¥{{fullBookPrice}}</text>
<text class="btn-discount">省82%</text>
</view>
</view>
</view>
<text class="paywall-tip">分享给好友一起学习,还能赚取佣金</text>
</view>
<!-- 章节导航 -->
<view class="chapter-nav chapter-nav-locked">
<view class="nav-buttons">
<view
class="nav-btn nav-prev {{!prevSection ? 'nav-disabled' : ''}}"
bindtap="goToPrev"
wx:if="{{prevSection}}"
>
<text class="btn-label">上一篇</text>
<text class="btn-title">章节 {{prevSection.id}}</text>
</view>
<view class="nav-btn-placeholder" wx:else></view>
<view
class="nav-btn nav-next"
bindtap="goToNext"
wx:if="{{nextSection}}"
>
<text class="btn-label">下一篇</text>
<view class="btn-row">
<text class="btn-title">{{nextSection.title}}</text>
<text class="btn-arrow">→</text>
</view>
</view>
<view class="nav-btn nav-end" wx:else>
<text class="btn-end-text">已是最后一篇 🎉</text>
</view>
</view>
</view>
</view>
<!-- 错误状态 - 网络异常 -->
<view class="article preview" wx:if="{{accessState === 'error'}}">
<view class="paragraph" wx:for="{{previewParagraphs}}" wx:key="index" wx:if="{{item}}">
{{item}}
</view>
<!-- 渐变遮罩 -->
<view class="fade-mask"></view>
<!-- 错误提示 -->
<view class="paywall">
<view class="paywall-icon">⚠️</view>
<text class="paywall-title">网络异常</text>
<text class="paywall-desc">无法确认权限,请检查网络后重试</text>
<view class="login-btn" bindtap="handleRetry">
<text class="login-btn-text">重新加载</text>
</view>
</view>
</view>
</view>
<!-- 海报生成弹窗 -->