/** * 卡若创业派对 - 小程序入口 * 开发: 卡若 */ const { parseScene } = require('./utils/scene.js') const { checkAndExecute } = require('./utils/ruleEngine.js') const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa' const DEFAULT_MCH_ID = '1318592501' const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE' App({ globalData: { // API 基础地址:开发时修改下面一行切换环境 // baseUrl: "https://soulapi.quwanzhi.com", baseUrl: 'http://localhost:8080', // 开发 // baseUrl: 'https://souldev.quwanzhi.com', // 测试 // 小程序配置 - 真实AppID appId: DEFAULT_APP_ID, // 订阅消息:用户点击「申请提现」→「立即提现」时会先弹出订阅授权窗 withdrawSubscribeTmplId: DEFAULT_WITHDRAW_TMPL_ID, // 微信支付配置 mchId: DEFAULT_MCH_ID, // 用户信息 userInfo: null, openId: null, // 微信openId,支付必需 isLoggedIn: false, // 书籍数据(bookData 由 chapters-by-part 等逐步填充,不再预加载 all-chapters) bookData: null, totalSections: 62, // 购买记录 purchasedSections: [], hasFullBook: false, // VIP 会员(365天,包含增值版免费;与 hasFullBook=9.9 买断不同) isVip: false, vipExpireDate: '', // 已读章节(仅统计有权限打开过的章节,用于首页「已读/待读」) readSectionIds: [], // 推荐绑定 pendingReferralCode: null, // 待绑定的推荐码 // 主题配置 theme: { brandColor: '#00CED1', brandSecondary: '#20B2AA', goldColor: '#FFD700', bgColor: '#000000', cardBg: '#1c1c1e' }, // 系统信息 systemInfo: null, statusBarHeight: 44, navBarHeight: 88, capsuleRightPadding: 96, // 胶囊右侧留白(px),getSystemInfo 会按 menuButton 计算 // TabBar相关 currentTab: 0, // 是否处于「单页模式」(如朋友圈文章里的单页预览) // 用于在受限环境下给出引导文案,提示用户点击底部「前往小程序」进入完整体验 isSinglePageMode: false, // 更新检测:上次检测时间戳,避免频繁请求 lastUpdateCheck: 0, // mpConfig 上次刷新时间戳(onShow 节流,避免频繁请求) lastMpConfigCheck: 0, // 审核模式:后端 /api/miniprogram/config 返回 auditMode=true 时隐藏所有支付相关UI auditMode: false, // 客服/微信:mp_config 返回 supportWechat supportWechat: '', // config 统一缓存(5min),减少重复请求 configCache: null, configCacheExpires: 0, // VIP 联系方式检测:上次检测时间戳,onShow 节流 5 分钟 lastVipContactCheck: 0, // 头像昵称检测:上次检测时间戳,onShow 节流 5 分钟 lastAvatarNicknameCheck: 0, }, onLaunch(options) { // 昵称等隐私组件需先授权:input type="nickname" 不会主动触发,需配合 wx.requirePrivacyAuthorize 使用 if (typeof wx.onNeedPrivacyAuthorization === 'function') { wx.onNeedPrivacyAuthorization((resolve) => { this._privacyResolve = resolve const pages = getCurrentPages() const cur = pages[pages.length - 1] const route = (cur && cur.route) || '' const needPrivacyPages = ['avatar-nickname', 'profile-edit', 'read', 'my', 'gift-pay/detail', 'index', 'settings'] const needShow = needPrivacyPages.some(p => route.includes(p)) if (cur && typeof cur.setData === 'function' && needShow) { cur.setData({ showPrivacyModal: true }) } else { resolve({ event: 'disagree' }) } }) } this.globalData.readSectionIds = wx.getStorageSync('readSectionIds') || [] // 加载 iconfont(字体图标)。注意:小程序不支持在 wxss 里用本地 @font-face 引用字体文件, // 需使用 loadFontFace 动态加载(字体文件建议走 https CDN)。 this.loadIconFont() // 获取系统信息 this.getSystemInfo() // 场景值兜底:1154 为「朋友圈单页模式」进入 try { const launchOpts = wx.getLaunchOptionsSync ? wx.getLaunchOptionsSync() : null if (launchOpts && launchOpts.scene === 1154) { this.globalData.isSinglePageMode = true } } catch (e) { console.warn('[App] 读取 LaunchOptions 失败:', e) } // 检查登录状态 this.checkLoginStatus() // 每次进入:先获取 VIP 状态,VIP 走 profile-edit,非 VIP 走头像/昵称引导(由 checkVipContactRequiredAndGuide 内部链式调用) if (this.globalData.isLoggedIn && this.globalData.userInfo?.id) { setTimeout(() => this.checkVipContactRequiredAndGuide(), 1500) setTimeout(() => this.connectWsHeartbeat(), 2000) } // 加载书籍数据 this.loadBookData() // 加载 mpConfig(appId、mchId、withdrawSubscribeTmplId 等,失败时保留默认值) this.loadMpConfig() // 检查更新 this.checkUpdate() // 处理分享参数(推荐码绑定) this.handleReferralCode(options) }, // 动态加载 iconfont(避免本地 @font-face 触发 do-not-use-local-path) loadIconFont() { if (!wx.loadFontFace) return // 来自 iconfont 项目(Project id 5142223) // 线上/真机需把 at.alicdn.com 加入「downloadFile 合法域名」 const urlWoff2 = 'https://at.alicdn.com/t/c/font_5142223_1sq6pv9vvbt.woff2' wx.loadFontFace({ family: 'iconfont', source: `url("${urlWoff2}")`, global: true, success: () => {}, fail: (e) => { console.warn('[Iconfont] loadFontFace failed:', e) }, }) }, // 小程序显示时:处理分享参数、检测更新、刷新审核模式(从后台切回时) onShow(options) { this.handleReferralCode(options) this.checkUpdate() // 从后台切回时仅刷新审核模式(轻量接口 /config/audit-mode),节流 30 秒 const now = Date.now() if (!this.globalData.lastMpConfigCheck || now - this.globalData.lastMpConfigCheck > 30 * 1000) { this.globalData.lastMpConfigCheck = now this.getAuditMode() } // 从后台切回:先 VIP 强制跳转,再头像/昵称,节流 5 分钟 const throttle = 5 * 60 * 1000 if (this.globalData.isLoggedIn && this.globalData.userInfo?.id) { if (!this.globalData.lastVipContactCheck || now - this.globalData.lastVipContactCheck > throttle) { this.globalData.lastVipContactCheck = now this.globalData.lastAvatarNicknameCheck = now setTimeout(() => this.checkVipContactRequiredAndGuide(), 500) } // 从后台切回:若 WSS 已断开则重连(微信后台可能回收连接) try { const need = !this._wsSocketTask || (this._wsSocketTask.readyState !== 0 && this._wsSocketTask.readyState !== 1) if (need) { this.clearWsReconnect() setTimeout(() => this.connectWsHeartbeat(), 1000) } } catch (_) {} } }, // 处理推荐码绑定:官方以 options.scene 接收扫码参数(可同时带 mid/id + ref),与 utils/scene 解析闭环 handleReferralCode(options) { const query = options?.query || {} let refCode = query.ref || query.referralCode const sceneStr = (options && (typeof options.scene === 'string' ? options.scene : '')) || '' if (sceneStr) { const parsed = parseScene(sceneStr) if (parsed.mid) this.globalData.initialSectionMid = parsed.mid if (parsed.id) this.globalData.initialSectionId = parsed.id if (parsed.ref) refCode = parsed.ref } if (refCode) { console.log('[App] 检测到推荐码:', refCode) // 立即记录访问(不需要登录,用于统计"通过链接进的人数") this.recordReferralVisit(refCode) // 保存待绑定的推荐码(不再在前端做"只能绑定一次"的限制,让后端根据30天规则判断续期/抢夺) this.globalData.pendingReferralCode = refCode wx.setStorageSync('pendingReferralCode', refCode) // 同步写入 referral_code,供章节/找伙伴支付时传给后端,订单会记录 referrer_id 与 referral_code wx.setStorageSync('referral_code', refCode) // 如果已登录,立即尝试绑定,由 /api/miniprogram/referral/bind 按 30 天规则决定 new / renew / takeover if (this.globalData.isLoggedIn && this.globalData.userInfo) { this.bindReferralCode(refCode) } } }, // 记录推荐访问(不需要登录,用于统计) async recordReferralVisit(refCode) { try { // 获取openId(如果有) const openId = this.globalData.openId || wx.getStorageSync('openId') || '' const userId = this.globalData.userInfo?.id || '' await this.request('/api/miniprogram/referral/visit', { method: 'POST', data: { referralCode: refCode, visitorOpenId: openId, visitorId: userId, source: 'miniprogram', page: getCurrentPages()[getCurrentPages().length - 1]?.route || '' }, silent: true }) console.log('[App] 记录推荐访问成功') } catch (e) { console.log('[App] 记录推荐访问失败:', e.message) // 忽略错误,不影响用户体验 } }, // 绑定推荐码到用户(自己的推荐码不请求接口,避免 400 与控制台报错) async bindReferralCode(refCode) { try { const userId = this.globalData.userInfo?.id if (!userId || !refCode) return const myCode = this.getMyReferralCode() if (myCode && this._normalizeReferralCode(refCode) === this._normalizeReferralCode(myCode)) { console.log('[App] 跳过绑定:不能使用自己的推荐码') this.globalData.pendingReferralCode = null wx.removeStorageSync('pendingReferralCode') return } console.log('[App] 绑定推荐码:', refCode, '到用户:', userId) const res = await this.request('/api/miniprogram/referral/bind', { method: 'POST', data: { userId, referralCode: refCode }, silent: true }) if (res.success) { console.log('[App] 推荐码绑定成功') wx.setStorageSync('boundReferralCode', refCode) this.globalData.pendingReferralCode = null wx.removeStorageSync('pendingReferralCode') } } catch (e) { const msg = (e && e.message) ? String(e.message) : '' if (msg.indexOf('不能使用自己的推荐码') !== -1) { console.log('[App] 跳过绑定:不能使用自己的推荐码') this.globalData.pendingReferralCode = null wx.removeStorageSync('pendingReferralCode') } else { console.error('[App] 绑定推荐码失败:', e) } } }, // 推荐码归一化后比较(忽略大小写、短横线等) _normalizeReferralCode(code) { if (!code || typeof code !== 'string') return '' return code.replace(/[\s\-_]/g, '').toUpperCase().trim() }, // 获取当前用户的邀请码(用于分享带 ref,未登录返回空字符串) getMyReferralCode() { const user = this.globalData.userInfo if (!user) return '' if (user.referralCode) return user.referralCode if (user.id) return 'SOUL' + String(user.id).toUpperCase().slice(-6) return '' }, /** * 自定义导航栏「返回」:有上一页则返回,否则跳转首页(解决从分享进入时点返回无效的问题) */ goBackOrToHome() { const pages = getCurrentPages() if (pages.length <= 1) { wx.switchTab({ url: '/pages/index/index' }) } else { wx.navigateBack() } }, // 获取系统信息 getSystemInfo() { try { const systemInfo = wx.getSystemInfoSync() this.globalData.systemInfo = systemInfo this.globalData.statusBarHeight = systemInfo.statusBarHeight || 44 // 微信在单页模式下会在 systemInfo.mode 标记 singlePage if (systemInfo.mode === 'singlePage') { this.globalData.isSinglePageMode = true } // 计算导航栏高度与胶囊避让 const menuButton = wx.getMenuButtonBoundingClientRect() if (menuButton) { this.globalData.navBarHeight = (menuButton.top - systemInfo.statusBarHeight) * 2 + menuButton.height + systemInfo.statusBarHeight // 胶囊右侧留白(px),供自定义导航栏避开胶囊 this.globalData.capsuleRightPadding = (systemInfo.windowWidth || 375) - menuButton.left + 8 } } catch (e) { console.error('获取系统信息失败:', e) } }, /** * 若当前处于朋友圈等「单页模式」,在尝试登录/购买前给用户友好提示, * 引导用户点击底部「前往小程序」进入完整小程序再操作。 * 返回 false 表示应中断当前操作。 */ ensureFullAppForAuth() { // 每次调用时再做一次兜底检测,避免全局标记遗漏 try { const sys = wx.getSystemInfoSync() if (sys && sys.mode === 'singlePage') { this.globalData.isSinglePageMode = true } } catch (e) { console.warn('[App] ensureFullAppForAuth getSystemInfoSync error:', e) } if (!this.globalData.isSinglePageMode) return true wx.showModal({ title: '请前往完整小程序', content: '当前为朋友圈单页,仅支持部分浏览。如需登录和解锁内容,请点击底部「前往小程序」后再操作。', showCancel: false, confirmText: '我知道了', }) return false }, /** 判断头像/昵称是否未完善(默认状态) */ _needsAvatarNickname(user) { const u = user || this.globalData.userInfo || {} const avatar = (u.avatar || u.avatarUrl || '').trim() const nickname = (u.nickname || u.nickName || '').trim() return !avatar || avatar.includes('default') || !nickname || nickname === '微信用户' || nickname.startsWith('微信用户') }, /** * 头像/昵称未改则引导:老用户弹窗后跳 avatar-nickname;新用户由登录处强制 redirectTo * VIP 用户不在此处理,统一走 checkVipContactRequiredAndGuide 只跳 profile-edit,避免乱跳 */ checkAvatarNicknameAndGuide() { if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return if (this.globalData.isVip) return // VIP 统一走 profile-edit,此处不触发 if (!this._needsAvatarNickname()) return try { const pages = getCurrentPages() const last = pages[pages.length - 1] const route = (last && last.route) || '' if (route.indexOf('profile-edit') !== -1 || route.indexOf('avatar-nickname') !== -1) return } catch (_) {} // 老用户:弹窗提示后跳转 const today = new Date().toISOString().slice(0, 10) const lastDate = wx.getStorageSync('lastAvatarGuideDate') || '' if (lastDate === today) return wx.setStorageSync('lastAvatarGuideDate', today) wx.showModal({ title: '完善个人资料', content: '请设置头像和昵称,让其他创业者更好地认识你', confirmText: '去完善', cancelText: '稍后', success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/avatar-nickname/avatar-nickname' }) } }) }, // 检查登录状态 checkLoginStatus() { try { const userInfo = wx.getStorageSync('userInfo') const token = wx.getStorageSync('token') if (userInfo && token) { this.globalData.userInfo = userInfo this.globalData.isLoggedIn = true this.globalData.purchasedSections = userInfo.purchasedSections || [] this.globalData.hasFullBook = userInfo.hasFullBook || false this.globalData.isVip = userInfo.isVip || false this.globalData.vipExpireDate = userInfo.vipExpireDate || '' // 若手机号为空,后台静默刷新用户资料以同步最新手机号(可能在其他设备/页面已绑定) if (!(userInfo.phone || '').trim()) { this._refreshUserInfoIfPhoneEmpty() } } } catch (e) { console.error('检查登录状态失败:', e) } }, /** * 手机号登录后:若响应中 user.phone 为空,从 profile 拉取最新资料并更新本地(后端已写入 DB) */ async _syncPhoneFromProfileAfterLogin(userId) { try { if (!userId) return const res = await this.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true }) const profile = res?.data if (!profile) return const phone = (profile.phone || '').trim() if (!phone) return const updated = { ...this.globalData.userInfo, phone } if (profile.wechatId != null) updated.wechatId = profile.wechatId this.globalData.userInfo = updated wx.setStorageSync('userInfo', updated) wx.setStorageSync('user_phone', phone) } catch (_) {} }, /** * 当本地 userInfo.phone 为空时,静默拉取 profile 并更新(用户可能在设置页或其他入口已绑定手机号) */ async _refreshUserInfoIfPhoneEmpty() { try { const userId = this.globalData.userInfo?.id if (!userId) return const res = await this.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true }) const profile = res?.data if (!profile) return const phone = (profile.phone || '').trim() if (!phone) return const updated = { ...this.globalData.userInfo, phone } if (profile.wechatId != null) updated.wechatId = profile.wechatId this.globalData.userInfo = updated wx.setStorageSync('userInfo', updated) if (phone) wx.setStorageSync('user_phone', phone) } catch (_) { // 静默失败,不影响主流程 } }, /** * WSS 在线心跳(占位):登录后连接 ws,发送 auth + 心跳,供管理端统计在线人数 * 容错:任意异常均不向外抛出,不影响登录、API 请求等核心功能 */ clearWsReconnect() { try { if (this._wsReconnectTimerId) { clearTimeout(this._wsReconnectTimerId) this._wsReconnectTimerId = null } this._wsReconnectDelay = 3000 } catch (_) {} }, scheduleWsReconnect() { try { if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return if (this._wsReconnectTimerId) return const delay = this._wsReconnectDelay || 3000 this._wsReconnectTimerId = setTimeout(() => { this._wsReconnectTimerId = null this._wsReconnectDelay = Math.min(60000, (this._wsReconnectDelay || 3000) * 2) this.connectWsHeartbeat() }, delay) } catch (_) {} }, connectWsHeartbeat() { try { this.clearWsReconnect() if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return const userId = this.globalData.userInfo.id const base = (this.globalData.baseUrl || '').replace(/\/$/, '') if (!base) return const wsUrl = base.replace(/^http/, 'ws') + '/ws/miniprogram' if (this._wsHeartbeatTimer) { clearInterval(this._wsHeartbeatTimer) this._wsHeartbeatTimer = null } if (this._wsSocketTask) { try { this._wsSocketTask.close() } catch (_) {} this._wsSocketTask = null } let task try { task = wx.connectSocket({ url: wsUrl, fail: () => { try { this.scheduleWsReconnect() } catch (_) {} } }) } catch (e) { if (typeof console !== 'undefined' && console.warn) console.warn('[WS] 连接失败(静默):', e?.message || e) try { this.scheduleWsReconnect() } catch (_) {} return } task.onOpen(() => { try { this.clearWsReconnect() task.send({ data: JSON.stringify({ type: 'auth', userId }) }) this._wsHeartbeatTimer = setInterval(() => { try { if (task && task.readyState === 1) task.send({ data: JSON.stringify({ type: 'heartbeat' }) }) } catch (_) {} }, 30000) } catch (_) {} }) task.onClose(() => { try { if (this._wsHeartbeatTimer) { clearInterval(this._wsHeartbeatTimer); this._wsHeartbeatTimer = null } this._wsSocketTask = null this.scheduleWsReconnect() } catch (_) {} }) task.onError(() => { try { if (this._wsHeartbeatTimer) { clearInterval(this._wsHeartbeatTimer); this._wsHeartbeatTimer = null } this._wsSocketTask = null this.scheduleWsReconnect() } catch (_) {} }) this._wsSocketTask = task } catch (e) { if (typeof console !== 'undefined' && console.warn) console.warn('[WS] 心跳异常(静默,不影响业务):', e?.message || e) } }, /** * VIP 用户登录后检测:手机号/头像昵称等需完善时,统一只跳 profile-edit,避免与 avatar-nickname 乱跳。 * 旧数据(VIP 但头像昵称未改):弹窗「为了更好服务,请完善资料」→ redirectTo profile-edit */ async checkVipContactRequiredAndGuide() { if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return const now = Date.now() if (this._lastVipGuideRun && now - this._lastVipGuideRun < 3000) return // 3 秒内不重复执行,避免 onLaunch+onShow 双重触发 this._lastVipGuideRun = now const userId = this.globalData.userInfo.id try { const pages = getCurrentPages() const last = pages[pages.length - 1] const route = (last && last.route) || '' if (route.indexOf('profile-edit') !== -1 || route.indexOf('avatar-nickname') !== -1) return } catch (_) {} try { const [vipRes, profileRes] = await Promise.all([ this.request({ url: `/api/miniprogram/vip/status?userId=${userId}`, silent: true }).catch(() => null), this.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true }).catch(() => null) ]) const isVip = vipRes?.data?.isVip || this.globalData.isVip || false this.globalData.isVip = isVip if (!isVip) { this.checkAvatarNicknameAndGuide() return } const profileData = profileRes?.data || this.globalData.userInfo || {} const phone = (profileData.phone || this.globalData.userInfo?.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '') const wechatId = (profileData.wechatId || profileData.wechat_id || this.globalData.userInfo?.wechatId || this.globalData.userInfo?.wechat_id || wx.getStorageSync('user_wechat') || '').trim() const needsAvatarNickname = this._needsAvatarNickname(profileData) // VIP 头像/昵称未改(含旧数据):统一只跳 profile-edit,弹窗「为了更好服务,请完善资料」 if (needsAvatarNickname) { wx.showModal({ title: '完善资料', content: '为了更好为您服务,请完善资料', confirmText: '去完善', showCancel: false, success: () => { wx.redirectTo({ url: '/pages/profile-edit/profile-edit' }) } }) return } if (phone && wechatId) return // VIP 无手机号:弹窗说明后跳转 if (!phone) { wx.showModal({ title: '完善资料', content: 'VIP会员需完善手机号,以便使用找伙伴、提现等功能', confirmText: '去完善', showCancel: false, success: () => { wx.redirectTo({ url: '/pages/profile-edit/profile-edit' }) } }) return } // 有手机号但缺微信号:弹窗引导(非强制) wx.showModal({ title: '完善联系方式', content: '请到资料页完善微信号,便于他人联系您', confirmText: '去完善', cancelText: '稍后', success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) } }) } catch (e) { console.log('[App] checkVipContactRequiredAndGuide 失败:', e?.message) } }, // 加载书籍元数据(totalSections),不再预加载 all-chapters async loadBookData() { try { const res = await this.request({ url: '/api/miniprogram/book/parts', silent: true }) if (res?.success && res.totalSections != null) { this.globalData.totalSections = res.totalSections } } catch (e) { try { const statsRes = await this.request({ url: '/api/miniprogram/book/stats', silent: true }) if (statsRes?.success && statsRes?.data?.totalChapters != null) { this.globalData.totalSections = statsRes.data.totalChapters } } catch (_) {} } }, /** * 获取 config(统一缓存 5min,各页优先读缓存) * 使用拆分接口 core + audit-mode,体积更小、审核模式独立刷新 * @param {boolean} forceRefresh - 强制刷新,跳过缓存 * @returns {Promise} 完整 config 或 null */ async getConfig(forceRefresh = false) { const now = Date.now() const CACHE_TTL = 5 * 60 * 1000 if (!forceRefresh && this.globalData.configCache && now < this.globalData.configCacheExpires) { return this.globalData.configCache } try { const [coreRes, auditRes] = await Promise.all([ this.request({ url: '/api/miniprogram/config/core', silent: true, timeout: 5000 }), this.request({ url: '/api/miniprogram/config/audit-mode', silent: true, timeout: 3000 }) ]) if (coreRes) { const auditMode = auditRes && typeof auditRes.auditMode === 'boolean' ? auditRes.auditMode : false const mp = (coreRes.mpConfig && typeof coreRes.mpConfig === 'object') ? { ...coreRes.mpConfig } : {} mp.auditMode = auditMode const res = { success: coreRes.success, prices: coreRes.prices, features: coreRes.features, userDiscount: coreRes.userDiscount, mpConfig: mp } this.globalData.configCache = res this.globalData.configCacheExpires = now + CACHE_TTL return res } } catch (e) { if (this.globalData.configCache) return this.globalData.configCache } return null }, /** * 获取阅读页扩展配置(linkTags、linkedMiniprograms),懒加载 */ async getReadExtras() { if (Array.isArray(this.globalData.linkTagsConfig) && this.globalData.linkTagsConfig.length > 0) { return { linkTags: this.globalData.linkTagsConfig, linkedMiniprograms: this.globalData.linkedMiniprograms || [] } } try { const res = await this.request({ url: '/api/miniprogram/config/read-extras', silent: true, timeout: 5000 }) if (res) { if (Array.isArray(res.linkTags)) this.globalData.linkTagsConfig = res.linkTags if (Array.isArray(res.linkedMiniprograms)) this.globalData.linkedMiniprograms = res.linkedMiniprograms return res } } catch (e) {} return { linkTags: [], linkedMiniprograms: [] } }, /** * 仅刷新审核模式(从后台切回时用,轻量) */ async getAuditMode() { try { const res = await this.request({ url: '/api/miniprogram/config/audit-mode', silent: true, timeout: 3000 }) if (res && typeof res.auditMode === 'boolean') { this.globalData.auditMode = res.auditMode if (this.globalData.configCache && this.globalData.configCache.mpConfig) { this.globalData.configCache.mpConfig.auditMode = res.auditMode } try { const pages = getCurrentPages() pages.forEach(p => { if (p && p.data && 'auditMode' in p.data) { p.setData({ auditMode: res.auditMode }) } }) } catch (_) {} return res.auditMode } } catch (e) {} return this.globalData.auditMode }, // 加载 mpConfig(appId、mchId、withdrawSubscribeTmplId、auditMode、supportWechat 等),失败时保留 globalData 默认值 async loadMpConfig() { try { const res = await this.getConfig() if (!res) return const mp = (res && res.mpConfig) || (res && res.configs && res.configs.mp_config) if (mp && typeof mp === 'object') { if (mp.appId) this.globalData.appId = mp.appId if (mp.mchId) this.globalData.mchId = mp.mchId if (mp.withdrawSubscribeTmplId) this.globalData.withdrawSubscribeTmplId = mp.withdrawSubscribeTmplId this.globalData.auditMode = !!mp.auditMode this.globalData.supportWechat = mp.supportWechat || mp.customerWechat || mp.serviceWechat || '' // 通知当前已加载的页面刷新 auditMode(从后台切回时配置更新后立即生效) try { const pages = getCurrentPages() pages.forEach(p => { if (p && p.data && 'auditMode' in p.data) { p.setData({ auditMode: this.globalData.auditMode || false }) } }) } catch (_) {} } } catch (e) { console.warn('[App] loadMpConfig 失败,使用默认值:', e?.message || e) } }, /** * 小程序更新检测(基于 wx.getUpdateManager) * - 启动时检测;从后台切回前台时也检测(间隔至少 5 分钟,避免频繁请求) */ checkUpdate() { try { if (!wx.canIUse('getUpdateManager')) return const now = Date.now() const lastCheck = this.globalData.lastUpdateCheck || 0 if (lastCheck && now - lastCheck < 5 * 60 * 1000) return // 5 分钟内不重复检测 this.globalData.lastUpdateCheck = now const updateManager = wx.getUpdateManager() updateManager.onCheckForUpdate((res) => { if (res.hasUpdate) { console.log('[App] 发现新版本,正在下载...') } }) updateManager.onUpdateReady(() => { wx.showModal({ title: '更新提示', content: '新版本已准备好,重启后即可使用', confirmText: '立即重启', cancelText: '稍后', success: (res) => { if (res.confirm) { updateManager.applyUpdate() } } }) }) updateManager.onUpdateFailed(() => { wx.showToast({ title: '更新失败,请稍后重试', icon: 'none', duration: 2500 }) }) } catch (e) { console.warn('[App] checkUpdate failed:', e) } }, /** * 从 soul-api 返回体中取错误提示文案(兼容 message / error 字段) */ _getApiErrorMsg(data, defaultMsg = '请求失败') { if (!data || typeof data !== 'object') return defaultMsg const msg = data.message || data.error return (msg && String(msg).trim()) ? String(msg).trim() : defaultMsg }, /** * 统一请求方法。接口失败时会弹窗提示(与 soul-api 返回的 message/error 一致)。 * GET 请求 200ms 内相同 url 去重,避免并发重复请求。 * @param {string|object} urlOrOptions - 接口路径,或 { url, method, data, header, silent } * @param {object} options - { method, data, header, silent } * @param {boolean} options.silent - 为 true 时不弹窗,仅 reject(用于静默请求如访问统计) */ request(urlOrOptions, options = {}) { let url if (typeof urlOrOptions === 'string') { url = urlOrOptions } else if (urlOrOptions && typeof urlOrOptions === 'object' && urlOrOptions.url) { url = urlOrOptions.url options = { ...urlOrOptions, url: undefined } } else { url = '' } const method = (options.method || 'GET').toUpperCase() const silent = !!options.silent const showError = (msg) => { if (!silent && msg) { wx.showToast({ title: msg, icon: 'none', duration: 2500 }) } } // GET 短时去重:相同 url 的并发请求共享同一 promise if (method === 'GET') { const dedupKey = url + (options.data ? JSON.stringify(options.data) : '') const pending = this._requestPending || (this._requestPending = {}) if (pending[dedupKey]) { return pending[dedupKey].promise } } const promise = new Promise((resolve, reject) => { const token = wx.getStorageSync('token') wx.request({ url: this.globalData.baseUrl + url, method: options.method || 'GET', data: options.data || {}, timeout: options.timeout || 15000, header: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '', ...options.header }, success: (res) => { const data = res.data if (res.statusCode === 200) { // 业务失败:success === false,soul-api 用 message 或 error 返回原因 if (data && data.success === false) { const msg = this._getApiErrorMsg(data, '操作失败') // 登录态不一致:本地有 token/userInfo,但后端查不到该用户 // 典型原因:切换环境(baseUrl)、换库/清库、用户被删除、token 与用户不匹配 if (msg && (msg.includes('用户不存在') || msg.toLowerCase().includes('user not found'))) { this.logout() } showError(msg) reject(new Error(msg)) return } resolve(data) return } if (res.statusCode === 401) { this.logout() showError('未授权,请重新登录') reject(new Error('未授权')) return } // 4xx/5xx:优先用返回体的 message/error const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败') showError(msg) reject(new Error(msg)) }, fail: (err) => { const msg = (err && err.errMsg) ? (err.errMsg.indexOf('timeout') !== -1 ? '请求超时,请重试' : '网络异常,请重试') : '网络异常,请重试' showError(msg) reject(new Error(msg)) } }) }) if (method === 'GET') { const dedupKey = url + (options.data ? JSON.stringify(options.data) : '') const pending = this._requestPending || (this._requestPending = {}) pending[dedupKey] = { promise, ts: Date.now() } promise.finally(() => { delete pending[dedupKey] }) } return promise }, // 登录方法 - 获取openId用于支付(加固错误处理,避免审核报“登录报错”) async login() { if (!this.ensureFullAppForAuth()) { return null } try { const loginRes = await new Promise((resolve, reject) => { wx.login({ success: resolve, fail: reject }) }) if (!loginRes || !loginRes.code) { console.warn('[App] wx.login 未返回 code') wx.showToast({ title: '获取登录态失败,请重试', icon: 'none' }) return null } try { const res = await this.request('/api/miniprogram/login', { method: 'POST', data: { code: loginRes.code } }) if (res.success && res.data) { // 保存openId if (res.data.openId) { this.globalData.openId = res.data.openId wx.setStorageSync('openId', res.data.openId) console.log('[App] 获取openId成功') } // 保存用户信息 if (res.data.user) { const user = res.data.user this.globalData.userInfo = user this.globalData.isLoggedIn = true this.globalData.purchasedSections = user.purchasedSections || [] this.globalData.hasFullBook = user.hasFullBook || false wx.setStorageSync('userInfo', user) wx.setStorageSync('token', res.data.token || '') // 登录成功后,检查待绑定的推荐码并执行绑定 const pendingRef = wx.getStorageSync('pendingReferralCode') || this.globalData.pendingReferralCode if (pendingRef) { console.log('[App] 登录后自动绑定推荐码:', pendingRef) this.bindReferralCode(pendingRef) } // 同步 isVip(与 checkLoginStatus 一致) this.globalData.isVip = user.isVip || false this.globalData.vipExpireDate = user.vipExpireDate || '' // 首次登录注册:强制跳转 avatar-nickname 修改头像昵称(不弹窗) if (res.isNewUser === true && this._needsAvatarNickname(user)) { setTimeout(() => wx.redirectTo({ url: '/pages/avatar-nickname/avatar-nickname' }), 1000) } else { checkAndExecute('after_login', null) setTimeout(() => this.checkVipContactRequiredAndGuide(), 1200) setTimeout(() => this.connectWsHeartbeat(), 2000) } } return res.data } } catch (apiError) { console.log('[App] API登录失败:', apiError.message) // 不使用模拟登录,提示用户网络问题 wx.showToast({ title: '网络异常,请重试', icon: 'none' }) return null } return null } catch (e) { console.error('[App] 登录失败:', e) wx.showToast({ title: '登录失败,请重试', icon: 'none' }) return null } }, // 获取openId (支付必需) async getOpenId() { if (!this.ensureFullAppForAuth()) { return null } // 先检查缓存 const cachedOpenId = wx.getStorageSync('openId') if (cachedOpenId) { this.globalData.openId = cachedOpenId return cachedOpenId } // 没有缓存则登录获取 try { const loginRes = await new Promise((resolve, reject) => { wx.login({ success: resolve, fail: reject }) }) const res = await this.request('/api/miniprogram/login', { method: 'POST', data: { code: loginRes.code } }) if (res.success && res.data?.openId) { this.globalData.openId = res.data.openId wx.setStorageSync('openId', res.data.openId) // 接口同时返回 user 时视为登录,补全登录态并从登录开始绑定推荐码 if (res.data.user) { const user = res.data.user this.globalData.userInfo = user this.globalData.isLoggedIn = true this.globalData.purchasedSections = user.purchasedSections || [] this.globalData.hasFullBook = user.hasFullBook || false wx.setStorageSync('userInfo', user) wx.setStorageSync('token', res.data.token || '') const pendingRef = wx.getStorageSync('pendingReferralCode') || this.globalData.pendingReferralCode if (pendingRef) { console.log('[App] getOpenId 登录后自动绑定推荐码:', pendingRef) this.bindReferralCode(pendingRef) } // 同步 isVip this.globalData.isVip = user.isVip || false this.globalData.vipExpireDate = user.vipExpireDate || '' // 首次登录注册:强制跳转 avatar-nickname if (res.isNewUser === true && this._needsAvatarNickname(user)) { setTimeout(() => wx.redirectTo({ url: '/pages/avatar-nickname/avatar-nickname' }), 1000) } else { checkAndExecute('after_login', null) setTimeout(() => this.checkVipContactRequiredAndGuide(), 1200) } } return res.data.openId } } catch (e) { console.error('[App] 获取openId失败:', e) } return null }, // 模拟登录已废弃 - 不再使用 // 现在必须使用真实的微信登录获取openId作为唯一标识 mockLogin() { console.warn('[App] mockLogin已废弃,请使用真实登录') return null }, // 手机号登录:需同时传 wx.login 的 code 与 getPhoneNumber 的 phoneCode async loginWithPhone(phoneCode) { if (!this.ensureFullAppForAuth()) { return null } try { const loginRes = await new Promise((resolve, reject) => { wx.login({ success: resolve, fail: reject }) }) if (!loginRes.code) { wx.showToast({ title: '获取登录态失败', icon: 'none' }) return null } const res = await this.request('/api/miniprogram/phone-login', { method: 'POST', data: { code: loginRes.code, phoneCode } }) if (res.success && res.data) { const user = res.data.user this.globalData.userInfo = user this.globalData.isLoggedIn = true this.globalData.purchasedSections = user.purchasedSections || [] this.globalData.hasFullBook = user.hasFullBook || false this.globalData.isVip = user.isVip || false this.globalData.vipExpireDate = user.vipExpireDate || '' wx.setStorageSync('userInfo', user) wx.setStorageSync('token', res.data.token) // 手机号登录后:若用户资料中手机号为空,从 profile 刷新并更新(后端已写入 DB,可能响应中未带回) const phone = (user.phone || '').trim() if (!phone) { this._syncPhoneFromProfileAfterLogin(user.id) } else { wx.setStorageSync('user_phone', phone) } // 登录成功后绑定推荐码 const pendingRef = wx.getStorageSync('pendingReferralCode') || this.globalData.pendingReferralCode if (pendingRef) { console.log('[App] 手机号登录后自动绑定推荐码:', pendingRef) this.bindReferralCode(pendingRef) } // 首次登录注册:强制跳转 avatar-nickname if (res.isNewUser === true && this._needsAvatarNickname(user)) { setTimeout(() => wx.redirectTo({ url: '/pages/avatar-nickname/avatar-nickname' }), 1000) } else { checkAndExecute('after_login', null) setTimeout(() => this.checkVipContactRequiredAndGuide(), 1200) } return res.data } } catch (e) { console.log('[App] 手机号登录失败:', e) wx.showToast({ title: '登录失败,请重试', icon: 'none' }) } return null }, // 退出登录 logout() { this.globalData.userInfo = null this.globalData.isLoggedIn = false this.globalData.purchasedSections = [] this.globalData.hasFullBook = false wx.removeStorageSync('userInfo') wx.removeStorageSync('token') }, // 检查是否已购买章节 hasPurchased(sectionId) { if (this.globalData.hasFullBook) return true return this.globalData.purchasedSections.includes(sectionId) }, // 标记章节为已读(仅在有权限打开时由阅读页调用,用于首页已读/待读统计) markSectionAsRead(sectionId) { if (!sectionId) return const list = this.globalData.readSectionIds || [] if (list.includes(sectionId)) return list.push(sectionId) this.globalData.readSectionIds = list wx.setStorageSync('readSectionIds', list) }, // 已读章节数(用于首页展示) getReadCount() { return (this.globalData.readSectionIds || []).length }, // 获取章节总数 getTotalSections() { return this.globalData.totalSections }, // 切换TabBar switchTab(index) { this.globalData.currentTab = index }, // 显示Toast showToast(title, icon = 'none') { wx.showToast({ title, icon, duration: 2000 }) }, // 显示Loading showLoading(title = '加载中...') { wx.showLoading({ title, mask: true }) }, // 隐藏Loading hideLoading() { wx.hideLoading() } })