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

1356 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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)
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)
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}`
// 邀请码:谁邀请了我(从落地页 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))
}
})