Files
soul-yongping/miniprogram/pages/read/read.js

1356 lines
46 KiB
JavaScript
Raw Normal View History

/**
* Soul创业派对 - 阅读页标准流程版
* 开发: 卡若
* 技术支持: 存客宝
*
* 更新: 2026-02-04
* - 引入权限管理器chapterAccessManager统一权限判断
* - 引入阅读追踪器readingTracker记录阅读进度时长是否读完
* - 使用状态机accessState规范权限流转
* - 异常统一保守处理避免误解锁
*/
import accessManager from '../../utils/chapterAccessManager'
import readingTracker from '../../utils/readingTracker'
const { buildScene, parseScene } = require('../../utils/scene.js')
const app = getApp()
Page({
data: {
// 系统信息
statusBarHeight: 44,
navBarHeight: 88,
// 章节信息
sectionId: '',
section: null,
partTitle: '',
chapterTitle: '',
// 内容
content: '',
previewContent: '',
contentParagraphs: [],
previewParagraphs: [],
loading: true,
// 【新增】权限状态机(替代 canAccess
// unknown: 加载中 | free: 免费 | locked_not_login: 未登录 | locked_not_purchased: 未购买 | unlocked_purchased: 已购买 | error: 错误
accessState: 'unknown',
// 用户状态
isLoggedIn: false,
hasFullBook: false,
canAccess: false, // 保留兼容性,从 accessState 派生
purchasedCount: 0,
// 阅读进度
readingProgress: 0,
showPaywall: false,
// 上一篇/下一篇
prevSection: null,
nextSection: null,
// 价格
sectionPrice: 1,
fullBookPrice: 9.9,
totalSections: 62,
// 好友优惠展示
userDiscount: 5,
hasReferralDiscount: false,
showDiscountHint: false,
displaySectionPrice: 1,
displayFullBookPrice: 9.9,
// 弹窗
showShareModal: false,
showLoginModal: false,
agreeProtocol: false,
showPosterModal: false,
isPaying: false,
isGeneratingPoster: false,
// 免费章节
freeIds: ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3'],
// 分享卡片图canvas 生成后写入,供 onShareAppMessage 使用)
shareImagePath: ''
},
async onLoad(options) {
// 官方以 options.scene 接收扫码参数(可同时带 mid/id + ref与海报生成 buildScene 闭环
const sceneStr = (options && options.scene) || ''
const parsed = parseScene(sceneStr)
const mid = options.mid ? parseInt(options.mid, 10) : (parsed.mid || app.globalData.initialSectionMid || 0)
const id = options.id || parsed.id || app.globalData.initialSectionId
const ref = options.ref || parsed.ref
if (app.globalData.initialSectionMid) delete app.globalData.initialSectionMid
if (app.globalData.initialSectionId) delete app.globalData.initialSectionId
console.log('[Read] onLoad:', { options, sceneRaw: sceneStr || undefined, parsed, mid, id, ref })
console.log('[Read] onLoad options:', options)
if (!mid && !id) {
console.warn('[Read] 未获取到章节 mid/idoptions:', options)
2026-02-12 15:17:39 +08:00
wx.showToast({ title: '章节参数缺失', icon: 'none' })
this.setData({ accessState: 'error', loading: false })
return
}
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
navBarHeight: app.globalData.navBarHeight,
sectionId: '', // 加载后填充
sectionMid: mid || null,
loading: true,
accessState: 'unknown'
})
if (ref) {
console.log('[Read] 检测到推荐码:', ref)
wx.setStorageSync('referral_code', ref)
app.handleReferralCode({ query: { ref } })
}
try {
const userId = app.globalData.userInfo?.id
const [config, purchaseRes] = await Promise.all([
accessManager.fetchLatestConfig(),
userId ? app.request(`/api/miniprogram/user/purchase-status?userId=${userId}`) : Promise.resolve(null)
])
const sectionPrice = config.prices?.section ?? 1
const fullBookPrice = config.prices?.fullbook ?? 9.9
const userDiscount = config.userDiscount ?? 5
// 有推荐人 = ref/ referral_code 或 用户信息中有推荐人绑定
const hasReferral = !!(wx.getStorageSync('referral_code') || ref || purchaseRes?.data?.hasReferrer)
const hasReferralDiscount = hasReferral && userDiscount > 0
const showDiscountHint = userDiscount > 0
const displaySectionPrice = hasReferralDiscount
? Math.round(sectionPrice * (1 - userDiscount / 100) * 100) / 100
: sectionPrice
const displayFullBookPrice = hasReferralDiscount
? Math.round(fullBookPrice * (1 - userDiscount / 100) * 100) / 100
: fullBookPrice
this.setData({
freeIds: config.freeChapters,
sectionPrice,
fullBookPrice,
userDiscount,
hasReferralDiscount,
showDiscountHint: userDiscount > 0,
displaySectionPrice,
displayFullBookPrice,
purchasedCount: purchaseRes?.data?.purchasedSections?.length ?? this.data.purchasedCount ?? 0
})
// 先拉取章节获取 idmid 时必需id 时可直接用)
let resolvedId = id
let prefetchedChapter = null
if (mid && !id) {
const chRes = await app.request(`/api/miniprogram/book/chapter/by-mid/${mid}`)
if (chRes && chRes.id) {
resolvedId = chRes.id
prefetchedChapter = chRes
}
}
this.setData({ sectionId: resolvedId })
const accessState = await accessManager.determineAccessState(resolvedId, config.freeChapters)
const canAccess = accessManager.canAccessFullContent(accessState)
this.setData({
accessState,
canAccess,
isLoggedIn: !!app.globalData.userInfo?.id,
showPaywall: !canAccess,
purchasedCount: purchaseRes?.data?.purchasedSections?.length ?? 0
})
await this.loadContent(mid, resolvedId, accessState, prefetchedChapter)
if (canAccess) {
readingTracker.init(resolvedId)
}
this.loadNavigation(resolvedId)
} catch (e) {
console.error('[Read] 初始化失败:', e)
wx.showToast({ title: '加载失败,请重试', icon: 'none' })
this.setData({ accessState: 'error', loading: false })
} finally {
this.setData({ loading: false })
}
},
// 从后端加载免费章节配置
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] && 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)
}
})
},
// 【重构】加载章节内容。mid 优先用 by-mid 接口id 用旧接口prefetched 避免重复请求
async loadContent(mid, id, accessState, prefetched) {
console.log('[Read] loadContent 请求参数:', { mid, id, accessState: accessState, prefetched: !!prefetched })
try {
const section = this.getSectionInfo(id)
2026-02-05 11:35:57 +08:00
const sectionPrice = this.data.sectionPrice ?? 1
if (section.price === undefined || section.price === null) {
section.price = sectionPrice
}
this.setData({ section })
let res = prefetched
if (!res) {
res = mid
? await app.request(`/api/miniprogram/book/chapter/by-mid/${mid}`)
: await app.request(`/api/miniprogram/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)
const updates = {
content: res.content,
contentParagraphs: lines,
previewParagraphs: lines.slice(0, previewCount),
partTitle: res.partTitle || '',
chapterTitle: res.chapterTitle || ''
}
if (res.mid) updates.sectionMid = res.mid
this.setData(updates)
if (accessManager.canAccessFullContent(accessState)) {
app.markSectionAsRead(id)
}
// 始终用接口返回的 price/isFree 更新 section不写死 1 元)
const section = this.data.section || {}
if (res.price !== undefined && res.price !== null) section.price = Number(res.price)
if (res.isFree !== undefined) section.isFree = !!res.isFree
// 0元即免费接口返回 price 为 0 或 isFree 为 true 时,不展示付费墙
const isFreeByPrice = res.price === 0 || res.price === '0' || Number(res.price) === 0
const isFreeByFlag = res.isFree === true
if (isFreeByPrice || isFreeByFlag) {
this.setData({ section, showPaywall: false, canAccess: true, accessState: 'free' })
app.markSectionAsRead(id)
} else {
this.setData({ section })
}
setTimeout(() => this.drawShareCard(), 600)
}
} catch (e) {
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
}
},
// 获取章节信息
getSectionInfo(id) {
// 特殊章节
if (id === 'preface') {
return { id: 'preface', title: '为什么我每天早上6点在Soul开播?', isFree: true, price: 0 }
}
if (id === 'epilogue') {
return { id: 'epilogue', title: '这本书的真实目的', isFree: true, price: 0 }
}
if (id.startsWith('appendix')) {
const appendixTitles = {
'appendix-1': 'Soul派对房精选对话',
'appendix-2': '创业者自检清单',
'appendix-3': '本书提到的工具和资源'
}
return { id, title: appendixTitles[id] || '附录', isFree: true, price: 0 }
}
// 普通章节price 不写死,由 loadContent 从 config/接口 填充
return {
id: id,
title: this.getSectionTitle(id),
isFree: id === '1.1',
price: undefined
}
},
// 获取章节标题
getSectionTitle(id) {
const titles = {
'1.1': '荷包:电动车出租的被动收入模式',
'1.2': '老墨:资源整合高手的社交方法',
'1.3': '笑声背后的MBTI',
'1.4': '人性的三角结构:利益、情感、价值观',
'1.5': '沟通差的问题:为什么你说的别人听不懂',
'2.1': '相亲故事:你以为找的是人,实际是在找模式',
'2.2': '找工作迷茫者:为什么简历解决不了人生',
'2.3': '撸运费险:小钱困住大脑的真实心理',
'2.4': '游戏上瘾的年轻人:不是游戏吸引他,是生活没吸引力',
'2.5': '健康焦虑(我的糖尿病经历):疾病是人生的第一次清醒',
'3.1': '3000万流水如何跑出来(退税模式解析)',
'8.1': '流量杠杆:抖音、Soul、飞书',
'9.14': '大健康私域一个月150万的70后'
}
return titles[id] || `章节 ${id}`
},
// 带超时的章节请求
fetchChapterWithTimeout(id, timeout = 5000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('请求超时'))
}, timeout)
app.request(`/api/miniprogram/book/chapter/${id}`)
.then(res => {
clearTimeout(timer)
resolve(res)
})
.catch(err => {
clearTimeout(timer)
reject(err)
})
})
},
// 设置章节内容
setChapterContent(res) {
const lines = res.content.split('\n').filter(line => line.trim())
const previewCount = Math.ceil(lines.length * 0.2)
this.setData({
content: res.content,
previewContent: lines.slice(0, previewCount).join('\n'),
contentParagraphs: lines,
previewParagraphs: lines.slice(0, previewCount),
partTitle: res.partTitle || '',
chapterTitle: res.chapterTitle || ''
})
},
// 静默刷新(后台更新缓存)
async silentRefresh(id) {
try {
const res = await this.fetchChapterWithTimeout(id, 10000)
if (res && res.content) {
wx.setStorageSync(`chapter_${id}`, res)
console.log('[Read] 后台缓存更新成功:', id)
}
} catch (e) {
// 静默失败不处理
}
},
// 重试加载
retryLoadContent(id, maxRetries, currentRetry = 0) {
if (currentRetry >= maxRetries) {
this.setData({
contentParagraphs: ['内容加载失败', '请检查网络连接后下拉刷新重试'],
previewParagraphs: ['内容加载失败']
})
return
}
setTimeout(async () => {
try {
const res = await this.fetchChapterWithTimeout(id, 8000)
if (res && res.content) {
this.setData({ section: this.getSectionInfo(id) })
this.setChapterContent(res)
wx.setStorageSync(`chapter_${id}`, res)
console.log('[Read] 重试成功:', id, '第', currentRetry + 1, '次')
setTimeout(() => this.drawShareCard(), 600)
return
}
} catch (e) {
console.warn('[Read] 重试失败,继续重试:', currentRetry + 1)
}
this.retryLoadContent(id, maxRetries, currentRetry + 1)
}, 2000 * (currentRetry + 1))
},
// 加载导航prevSection/nextSection 含 mid 时用于跳转,否则用 id
loadNavigation(id) {
const sectionOrder = [
'preface', '1.1', '1.2', '1.3', '1.4', '1.5',
'2.1', '2.2', '2.3', '2.4', '2.5',
'3.1', '3.2', '3.3', '3.4',
'4.1', '4.2', '4.3', '4.4', '4.5',
'5.1', '5.2', '5.3', '5.4', '5.5',
'6.1', '6.2', '6.3', '6.4',
'7.1', '7.2', '7.3', '7.4', '7.5',
'8.1', '8.2', '8.3', '8.4', '8.5', '8.6',
'9.1', '9.2', '9.3', '9.4', '9.5', '9.6', '9.7', '9.8', '9.9', '9.10', '9.11', '9.12', '9.13', '9.14',
'10.1', '10.2', '10.3', '10.4',
'11.1', '11.2', '11.3', '11.4', '11.5',
'epilogue'
]
const bookData = app.globalData.bookData || []
const idToMid = {}
bookData.forEach(ch => {
if (ch.id && ch.mid) idToMid[ch.id] = ch.mid
})
const currentIndex = sectionOrder.indexOf(id)
const prevId = currentIndex > 0 ? sectionOrder[currentIndex - 1] : null
const nextId = currentIndex < sectionOrder.length - 1 ? sectionOrder[currentIndex + 1] : null
this.setData({
prevSection: prevId ? { id: prevId, mid: idToMid[prevId], title: this.getSectionTitle(prevId) } : null,
nextSection: nextId ? { id: nextId, mid: idToMid[nextId], title: this.getSectionTitle(nextId) } : null
})
},
// 返回
goBack() {
wx.navigateBack({
fail: () => wx.switchTab({ url: '/pages/chapters/chapters' })
})
},
// 分享弹窗
showShare() {
this.setData({ showShareModal: true })
},
closeShareModal() {
this.setData({ showShareModal: false })
},
// 复制分享文案(朋友圈风格)
copyShareText() {
const { section } = this.data
const shareText = `🔥 刚看完这篇《${section?.title || 'Soul创业派对'}》,太上头了!
62个真实商业案例每个都是从0到1的实战经验私域运营资源整合商业变现干货满满
推荐给正在创业或想创业的朋友"Soul创业派对"小程序就能看
#创业派对 #私域运营 #商业案例`
wx.setClipboardData({
data: shareText,
success: () => {
wx.showToast({ title: '文案已复制', icon: 'success' })
}
})
},
// 绘制分享卡片图(标题+正文摘要),生成后供 onShareAppMessage 使用
drawShareCard() {
const { section, sectionId, contentParagraphs } = this.data
const title = section?.title || this.getSectionTitle(sectionId) || '精彩内容'
const raw = (contentParagraphs && contentParagraphs.length)
? contentParagraphs.slice(0, 4).join(' ').replace(/\s+/g, ' ').trim()
: ''
const excerpt = raw.length > 120 ? raw.slice(0, 120) + '...' : (raw || '来自派对房的真实商业故事')
const ctx = wx.createCanvasContext('shareCardCanvas', this)
const w = 500
const h = 400
// 白底
ctx.setFillStyle('#ffffff')
ctx.fillRect(0, 0, w, h)
// 顶部:平台名
ctx.setFillStyle('#333333')
ctx.setFontSize(14)
ctx.fillText('📚 Soul 创业派对 - 真实商业故事', 24, 36)
// 深色内容区(模拟参考图效果)
const boxX = 24
const boxY = 52
const boxW = w - 48
const boxH = 300
ctx.setFillStyle('#2c2c2e')
ctx.fillRect(boxX, boxY, boxW, boxH)
// 文章标题(白字)
ctx.setFillStyle('#ffffff')
ctx.setFontSize(15)
const titleLines = this.wrapText(ctx, title.length > 50 ? title.slice(0, 50) + '...' : title, boxW - 32, 15)
let y = boxY + 28
titleLines.slice(0, 2).forEach(line => {
ctx.fillText(line, boxX + 16, y)
y += 22
})
y += 8
// 正文摘要(浅灰)
ctx.setFillStyle('rgba(255,255,255,0.88)')
ctx.setFontSize(12)
const excerptLines = this.wrapText(ctx, excerpt, boxW - 32, 12)
excerptLines.slice(0, 8).forEach(line => {
ctx.fillText(line, boxX + 16, y)
y += 20
})
// 底部:小程序标识
ctx.setFillStyle('#999999')
ctx.setFontSize(11)
ctx.fillText('小程序', 24, h - 16)
ctx.draw(false, () => {
wx.canvasToTempFilePath({
canvasId: 'shareCardCanvas',
fileType: 'png',
success: (res) => {
this.setData({ shareImagePath: res.tempFilePath })
}
}, this)
})
},
// 统一分享配置(底部「推荐给好友」与右下角分享按钮均走此配置,由 onShareAppMessage 使用)
getShareConfig() {
const { section, sectionId, sectionMid, shareImagePath } = this.data
const ref = app.getMyReferralCode()
const shareTitle = section?.title
? `📚 ${section.title.length > 20 ? section.title.slice(0, 20) + '...' : section.title}`
: '📚 Soul创业派对 - 真实商业故事'
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
const path = ref ? `/pages/read/read?${q}&ref=${ref}` : `/pages/read/read?${q}`
return {
title: shareTitle,
path,
imageUrl: shareImagePath || undefined
}
},
onShareAppMessage() {
return this.getShareConfig()
},
onShareTimeline() {
const { section, sectionId, sectionMid } = this.data
const ref = app.getMyReferralCode()
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
return {
title: `${section?.title || 'Soul创业派对'} - 来自派对房的真实故事`,
query: ref ? `${q}&ref=${ref}` : q
}
},
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)
showLoginModal() {
try {
this.setData({ showLoginModal: true, agreeProtocol: false })
} catch (e) {
console.error('[Read] showLoginModal error:', e)
this.setData({ showLoginModal: true })
}
},
closeLoginModal() {
this.setData({ showLoginModal: false })
},
toggleAgree() {
this.setData({ agreeProtocol: !this.data.agreeProtocol })
},
openUserProtocol() {
wx.navigateTo({ url: '/pages/agreement/agreement' })
},
openPrivacy() {
wx.navigateTo({ url: '/pages/privacy/privacy' })
},
// 从服务端刷新购买状态,避免登录后误用旧数据导致误解锁
// 【重构】微信登录(须先勾选同意协议,符合审核要求)
async handleWechatLogin() {
if (!this.data.agreeProtocol) {
wx.showToast({ title: '请先阅读并同意用户协议和隐私政策', icon: 'none' })
return
}
try {
const result = await app.login()
if (!result) return
this.setData({ showLoginModal: false, agreeProtocol: 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()
}
try {
const result = await app.loginWithPhone(e.detail.code)
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 userId = app.globalData.userInfo?.id
const [config, purchaseRes] = await Promise.all([
accessManager.fetchLatestConfig(),
userId ? app.request(`/api/miniprogram/user/purchase-status?userId=${userId}`) : Promise.resolve(null)
])
const sectionPrice = config.prices?.section ?? this.data.sectionPrice ?? 1
const fullBookPrice = config.prices?.fullbook ?? this.data.fullBookPrice ?? 9.9
const userDiscount = config.userDiscount ?? 5
const hasReferral = !!(wx.getStorageSync('referral_code') || purchaseRes?.data?.hasReferrer)
const hasReferralDiscount = hasReferral && userDiscount > 0
const displaySectionPrice = hasReferralDiscount
? Math.round(sectionPrice * (1 - userDiscount / 100) * 100) / 100
: sectionPrice
const displayFullBookPrice = hasReferralDiscount
? Math.round(fullBookPrice * (1 - userDiscount / 100) * 100) / 100
: fullBookPrice
this.setData({
freeIds: config.freeChapters,
sectionPrice,
fullBookPrice,
userDiscount,
hasReferralDiscount,
showDiscountHint: userDiscount > 0,
displaySectionPrice,
displayFullBookPrice,
purchasedCount: purchaseRes?.data?.purchasedSections?.length ?? this.data.purchasedCount ?? 0
})
// 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.sectionMid, this.data.sectionId, newAccessState, null)
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] 点击购买章节按钮')
wx.showLoading({ title: '处理中...', mask: true })
if (!this.data.isLoggedIn) {
wx.hideLoading()
console.log('[Pay] 用户未登录,显示登录弹窗')
this.setData({ showLoginModal: true })
return
}
const price = this.data.section?.price ?? this.data.sectionPrice ?? 1
console.log('[Pay] 开始支付流程:', { sectionId: this.data.sectionId, price })
wx.hideLoading()
await this.processPayment('section', this.data.sectionId, price)
},
// 购买全书 - 直接调起支付
async handlePurchaseFullBook() {
console.log('[Pay] 点击购买全书按钮')
wx.showLoading({ title: '处理中...', mask: true })
if (!this.data.isLoggedIn) {
wx.hideLoading()
console.log('[Pay] 用户未登录,显示登录弹窗')
this.setData({ showLoginModal: true })
return
}
console.log('[Pay] 开始支付流程: 全书', { price: this.data.fullBookPrice })
wx.hideLoading()
await this.processPayment('fullbook', null, this.data.fullBookPrice)
},
// 处理支付 - 调用真实微信支付接口
async processPayment(type, sectionId, amount) {
console.log('[Pay] processPayment开始:', { type, sectionId, amount })
// 检查金额是否有效
if (!amount || amount <= 0) {
console.error('[Pay] 金额无效:', amount)
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/miniprogram/user/purchase-status?userId=${userId}`)
if (checkRes.success && checkRes.data) {
// 更新本地购买状态
app.globalData.hasFullBook = checkRes.data.hasFullBook
app.globalData.purchasedSections = checkRes.data.purchasedSections || []
app.globalData.sectionMidMap = checkRes.data.sectionMidMap || {}
// 检查是否已购买
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
}
}
}
} catch (e) {
console.warn('[Pay] 查询购买状态失败,继续支付流程:', e)
// 查询失败不影响支付
}
this.setData({ isPaying: true })
wx.showLoading({ title: '正在发起支付...', mask: true })
try {
// 1. 先获取openId (支付必需)
let openId = app.globalData.openId || wx.getStorageSync('openId')
if (!openId) {
console.log('[Pay] 需要先获取openId尝试静默获取')
wx.showLoading({ title: '获取支付凭证...', mask: true })
openId = await app.getOpenId()
if (!openId) {
// openId获取失败但已登录用户可以使用用户ID替代
if (app.globalData.isLoggedIn && app.globalData.userInfo?.id) {
console.log('[Pay] 使用用户ID作为替代')
openId = app.globalData.userInfo.id
} else {
wx.hideLoading()
wx.showModal({
title: '提示',
content: '需要登录后才能支付,请先登录',
showCancel: false
})
this.setData({ showLoginModal: true, isPaying: false })
return
}
}
}
console.log('[Pay] 开始创建订单:', { type, sectionId, amount, openId: openId.slice(0, 10) + '...' })
wx.showLoading({ title: '创建订单中...', mask: true })
// 2. 调用后端创建预支付订单
let paymentData = null
try {
// 获取章节完整名称用于支付描述
const sectionTitle = this.data.section?.title || sectionId
const description = type === 'fullbook'
? '《一场Soul的创业实验》全书'
: `章节${sectionId}-${sectionTitle.length > 20 ? sectionTitle.slice(0, 20) + '...' : sectionTitle}`
2026-02-05 11:35:57 +08:00
// 邀请码:谁邀请了我(从落地页 ref 或 storage 带入),会写入订单 referrer_id / referral_code 便于分销与对账
const referralCode = wx.getStorageSync('referral_code') || ''
const res = await app.request('/api/miniprogram/pay', {
method: 'POST',
data: {
openId,
productType: type,
productId: sectionId,
amount,
description,
userId: app.globalData.userInfo?.id || '',
referralCode: referralCode || undefined
}
})
console.log('[Pay] 创建订单响应:', res)
if (res.success && res.data?.payParams) {
paymentData = res.data.payParams
paymentData._orderSn = res.data.orderSn
console.log('[Pay] 获取支付参数成功, orderSn:', res.data.orderSn)
} else {
throw new Error(res.error || res.message || '创建订单失败')
}
} catch (apiError) {
console.error('[Pay] API创建订单失败:', apiError)
wx.hideLoading()
// 支付接口失败时,显示客服联系方式
wx.showModal({
title: '支付通道维护中',
content: '微信支付正在审核中请添加客服微信28533368手动购买感谢理解',
confirmText: '复制微信号',
cancelText: '稍后再说',
success: (res) => {
if (res.confirm) {
wx.setClipboardData({
data: '28533368',
success: () => {
wx.showToast({ title: '微信号已复制', icon: 'success' })
}
})
}
}
})
this.setData({ isPaying: false })
return
}
// 3. 调用微信支付
wx.hideLoading()
console.log('[Pay] 调起微信支付, paymentData:', paymentData)
try {
const orderSn = paymentData._orderSn
await this.callWechatPay(paymentData)
// 4. 轮询订单状态确认已支付后刷新并解锁(不依赖 PayNotify 回调时机)
console.log('[Pay] 微信支付成功!')
await this.onPaymentSuccess(orderSn)
} catch (payErr) {
console.error('[Pay] 微信支付调起失败:', payErr)
if (payErr.errMsg && payErr.errMsg.includes('cancel')) {
wx.showToast({ title: '已取消支付', icon: 'none' })
} else if (payErr.errMsg && payErr.errMsg.includes('requestPayment:fail')) {
// 支付失败,可能是参数错误或权限问题
wx.showModal({
title: '支付失败',
content: '微信支付暂不可用请添加客服微信28533368手动购买',
confirmText: '复制微信号',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
wx.setClipboardData({
data: '28533368',
success: () => wx.showToast({ title: '微信号已复制', icon: 'success' })
})
}
}
})
} else {
wx.showToast({ title: payErr.errMsg || '支付失败', icon: 'none' })
}
}
} catch (e) {
console.error('[Pay] 支付流程异常:', e)
wx.hideLoading()
wx.showToast({ title: '支付出错,请重试', icon: 'none' })
} finally {
this.setData({ isPaying: false })
}
},
// 轮询订单状态,确认 paid 后刷新权限并解锁
async pollOrderUntilPaid(orderSn) {
const maxAttempts = 15
const interval = 800
for (let i = 0; i < maxAttempts; i++) {
try {
const r = await app.request(`/api/miniprogram/pay?orderSn=${encodeURIComponent(orderSn)}`, { method: 'GET', silent: true })
if (r?.data?.status === 'paid') return true
} catch (_) {}
if (i < maxAttempts - 1) await this.sleep(interval)
}
return false
},
// 【新增】支付成功后的标准处理流程
async onPaymentSuccess(orderSn) {
wx.showLoading({ title: '确认购买中...', mask: true })
try {
// 1. 轮询订单状态直到已支付GET pay 会主动同步本地订单,不依赖 PayNotify
if (orderSn) {
const paid = await this.pollOrderUntilPaid(orderSn)
if (!paid) {
console.warn('[Pay] 轮询超时,仍尝试刷新')
}
} else {
await this.sleep(1500)
}
// 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.sectionMid, this.data.sectionId, newAccessState, null)
// 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/miniprogram/user/purchase-status?userId=${userId}`)
if (res.success && res.data) {
// 更新全局购买状态
app.globalData.hasFullBook = res.data.hasFullBook
app.globalData.purchasedSections = res.data.purchasedSections || []
app.globalData.sectionMidMap = res.data.sectionMidMap || {}
app.globalData.matchCount = res.data.matchCount ?? 0
app.globalData.matchQuota = res.data.matchQuota || null
// 更新用户信息中的购买记录
const userInfo = app.globalData.userInfo || {}
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,
matchCount: res.data.matchCount
})
}
} catch (e) {
console.error('[Pay] 刷新购买状态失败:', e)
// 刷新失败时不影响用户体验,只是记录日志
}
},
// 调用微信支付
callWechatPay(paymentData) {
return new Promise((resolve, reject) => {
wx.requestPayment({
timeStamp: paymentData.timeStamp,
nonceStr: paymentData.nonceStr,
package: paymentData.package,
signType: paymentData.signType || 'MD5',
paySign: paymentData.paySign,
success: resolve,
fail: reject
})
})
},
goToPrev() {
const s = this.data.prevSection
if (s) {
const q = s.mid ? `mid=${s.mid}` : `id=${s.id}`
wx.redirectTo({ url: `/pages/read/read?${q}` })
}
},
goToNext() {
const s = this.data.nextSection
if (s) {
const q = s.mid ? `mid=${s.mid}` : `id=${s.id}`
wx.redirectTo({ url: `/pages/read/read?${q}` })
}
},
// 跳转到推广中心
goToReferral() {
wx.navigateTo({ url: '/pages/referral/referral' })
},
// 生成海报(弹窗先展示,延迟再绘制,确保 canvas 已渲染)
async generatePoster() {
wx.showLoading({ title: '生成中...' })
this.setData({ showPosterModal: true, isGeneratingPoster: true })
const { section, contentParagraphs, sectionId, sectionMid } = this.data
const userInfo = app.globalData.userInfo
const userId = userInfo?.id || ''
const safeParagraphs = contentParagraphs || []
// 与 utils/scene 闭环:生成 scene 用 buildScene扫码后用 parseScene 解析
let qrcodeTempPath = null
try {
const refVal = userId ? String(userId).slice(0, 12) : ''
const scene = buildScene({
mid: sectionMid || undefined,
id: sectionMid ? undefined : (sectionId || ''),
ref: refVal || undefined
})
const baseUrl = app.globalData.baseUrl || ''
const url = `${baseUrl}/api/miniprogram/qrcode/image?scene=${encodeURIComponent(scene)}&page=${encodeURIComponent('pages/read/read')}&width=280`
qrcodeTempPath = await new Promise((resolve) => {
wx.downloadFile({
url,
success: (res) => resolve(res.statusCode === 200 ? res.tempFilePath : null),
fail: () => resolve(null)
})
})
} catch (e) {
console.log('[Poster] 获取小程序码失败,使用占位符')
}
const doDraw = () => {
try {
const ctx = wx.createCanvasContext('posterCanvas', this)
const width = 300
const height = 450
const grd = ctx.createLinearGradient(0, 0, 0, height)
grd.addColorStop(0, '#1a1a2e')
grd.addColorStop(1, '#16213e')
ctx.setFillStyle(grd)
ctx.fillRect(0, 0, width, height)
ctx.setFillStyle('#00CED1')
ctx.fillRect(0, 0, width, 4)
ctx.setFillStyle('#ffffff')
ctx.setFontSize(14)
ctx.fillText('📚 Soul创业派对', 20, 35)
ctx.setFontSize(18)
ctx.setFillStyle('#ffffff')
const title = section?.title || this.getSectionTitle(sectionId) || '精彩内容'
const titleLines = this.wrapText(ctx, title, width - 40, 18)
let y = 70
titleLines.forEach(line => {
ctx.fillText(line, 20, y)
y += 26
})
ctx.setStrokeStyle('rgba(255,255,255,0.1)')
ctx.beginPath()
ctx.moveTo(20, y + 10)
ctx.lineTo(width - 20, y + 10)
ctx.stroke()
ctx.setFontSize(12)
ctx.setFillStyle('rgba(255,255,255,0.8)')
y += 30
const summary = safeParagraphs.slice(0, 3).join(' ').replace(/\s+/g, ' ').trim().slice(0, 150)
const summaryText = summary ? summary + (summary.length >= 150 ? '...' : '') : '来自派对房的真实商业故事'
const summaryLines = this.wrapText(ctx, summaryText, width - 40, 12)
summaryLines.slice(0, 6).forEach(line => {
ctx.fillText(line, 20, y)
y += 20
})
ctx.setFillStyle('rgba(0,206,209,0.1)')
ctx.fillRect(0, height - 100, width, 100)
ctx.setFillStyle('#ffffff')
ctx.setFontSize(13)
ctx.fillText('长按识别小程序码', 20, height - 60)
ctx.setFillStyle('rgba(255,255,255,0.6)')
ctx.setFontSize(11)
ctx.fillText('长按小程序码阅读全文', 20, height - 38)
const drawQRCode = () => {
return new Promise((resolve) => {
if (qrcodeTempPath) {
ctx.drawImage(qrcodeTempPath, width - 85, height - 85, 70, 70)
} else {
this.drawQRPlaceholder(ctx, width, height)
}
resolve()
})
}
drawQRCode().then(() => {
ctx.draw(true, () => {
wx.hideLoading()
this.setData({ isGeneratingPoster: false })
})
})
} catch (e) {
console.error('生成海报失败:', e)
wx.hideLoading()
wx.showToast({ title: '生成失败', icon: 'none' })
this.setData({ showPosterModal: false, isGeneratingPoster: false })
}
}
setTimeout(doDraw, 400)
},
// 绘制小程序码占位符
drawQRPlaceholder(ctx, width, height) {
ctx.setFillStyle('#ffffff')
ctx.beginPath()
ctx.arc(width - 50, height - 50, 35, 0, Math.PI * 2)
ctx.fill()
ctx.setFillStyle('#00CED1')
ctx.setFontSize(9)
ctx.fillText('扫码', width - 57, height - 52)
ctx.fillText('阅读', width - 57, height - 40)
},
// 文字换行处理
wrapText(ctx, text, maxWidth, fontSize) {
const lines = []
let line = ''
for (let i = 0; i < text.length; i++) {
const testLine = line + text[i]
const metrics = ctx.measureText(testLine)
if (metrics.width > maxWidth && line) {
lines.push(line)
line = text[i]
} else {
line = testLine
}
}
if (line) lines.push(line)
return lines
},
// 关闭海报弹窗
closePosterModal() {
this.setData({ showPosterModal: false })
},
// 保存海报到相册:画布 300x450 兼容 iOS导出 2 倍 600x900 提升清晰度(宽高比 2:3 不变)
savePoster() {
const width = 300
const height = 450
const exportScale = 2
wx.canvasToTempFilePath({
canvasId: 'posterCanvas',
destWidth: width * exportScale,
destHeight: height * exportScale,
fileType: 'png',
success: (res) => {
if (!res.tempFilePath) {
wx.showToast({ title: '生成图片失败', icon: 'none' })
return
}
wx.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
wx.showToast({ title: '已保存到相册', icon: 'success' })
this.setData({ showPosterModal: false })
},
fail: (err) => {
console.error('[savePoster] saveImageToPhotosAlbum fail:', err)
if (err.errMsg && (err.errMsg.includes('auth deny') || err.errMsg.includes('authorize'))) {
wx.showModal({
title: '提示',
content: '需要相册权限才能保存海报',
confirmText: '去设置',
success: (sres) => {
if (sres.confirm) wx.openSetting()
}
})
} else {
wx.showToast({ title: err.errMsg || '保存失败', icon: 'none' })
}
}
})
},
fail: (err) => {
console.error('[savePoster] canvasToTempFilePath fail:', err)
wx.showToast({ title: err.errMsg || '生成图片失败', icon: 'none' })
}
}, this)
},
// 阻止冒泡
stopPropagation() {},
// 【新增】页面隐藏时上报阅读进度
onHide() {
readingTracker.onPageHide()
},
// 【新增】页面卸载时清理追踪器
onUnload() {
readingTracker.cleanup()
},
// 【新增】重试加载(当 accessState 为 error 时)
async handleRetry() {
wx.showLoading({ title: '重试中...', mask: true })
try {
const userId = app.globalData.userInfo?.id
const [config, purchaseRes] = await Promise.all([
accessManager.fetchLatestConfig(),
userId ? app.request(`/api/miniprogram/user/purchase-status?userId=${userId}`) : Promise.resolve(null)
])
const sectionPrice = config.prices?.section ?? this.data.sectionPrice ?? 1
const fullBookPrice = config.prices?.fullbook ?? this.data.fullBookPrice ?? 9.9
const userDiscount = config.userDiscount ?? 5
const hasReferral = !!(wx.getStorageSync('referral_code') || purchaseRes?.data?.hasReferrer)
const hasReferralDiscount = hasReferral && userDiscount > 0
const displaySectionPrice = hasReferralDiscount
? Math.round(sectionPrice * (1 - userDiscount / 100) * 100) / 100
: sectionPrice
const displayFullBookPrice = hasReferralDiscount
? Math.round(fullBookPrice * (1 - userDiscount / 100) * 100) / 100
: fullBookPrice
this.setData({
freeIds: config.freeChapters,
sectionPrice,
fullBookPrice,
userDiscount,
hasReferralDiscount,
showDiscountHint: userDiscount > 0,
displaySectionPrice,
displayFullBookPrice,
purchasedCount: purchaseRes?.data?.purchasedSections?.length ?? this.data.purchasedCount ?? 0
})
// 重新判断权限
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.sectionMid, this.data.sectionId, newAccessState, null)
// 如果有权限,初始化阅读追踪
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))
}
})