/** * 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/id,options:', 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 }) // 先拉取章节获取 id(mid 时必需;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)) } })