Files
soul-yongping/miniprogram/app.js

1333 lines
49 KiB
JavaScript
Raw Normal View History

/**
* 卡若创业派对 - 小程序入口
* 开发: 卡若
*/
const { parseScene } = require('./utils/scene.js')
2026-03-17 12:21:33 +08:00
const { checkAndExecute } = require('./utils/ruleEngine.js')
2026-03-17 18:22:06 +08:00
const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
const DEFAULT_MCH_ID = '1318592501'
const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
const PRODUCTION_BASE_URL = 'https://soulapi.quwanzhi.com'
const CONFIG_CACHE_KEY = 'mpConfigCacheV1'
// 与上传版本号对齐;设置页展示优先用 wx.getAccountInfoSync().miniProgram.version正式版否则用本字段
const APP_DISPLAY_VERSION = '1.7.2'
2026-03-17 18:22:06 +08:00
App({
globalData: {
// 与微信后台上传版本号一致,供设置页等展示(避免与线上 version 字段混淆)
appDisplayVersion: APP_DISPLAY_VERSION,
// API仓库默认生产release 强制生产develop/trial 可读 storage「apiBaseUrl」或用 env-switch
baseUrl: 'https://soulapi.quwanzhi.com',
// 小程序配置 - 真实AppID
2026-03-18 12:40:51 +08:00
appId: DEFAULT_APP_ID,
// 订阅消息:用户点击「申请提现」→「立即提现」时会先弹出订阅授权窗
2026-03-18 12:40:51 +08:00
withdrawSubscribeTmplId: DEFAULT_WITHDRAW_TMPL_ID,
// 微信支付配置
2026-03-18 12:40:51 +08:00
mchId: DEFAULT_MCH_ID,
// 用户信息
userInfo: null,
openId: null, // 微信openId支付必需
isLoggedIn: false,
// 阅读页 @ 解析:/config/read-extras 的 mentionPersons与后台 persons + token 一致)
mentionPersons: [],
// 是否已成功拉取过 read-extras避免仅 linkTags 有缓存时永远拿不到 mentionPersons
readExtrasCacheValid: false,
// 书籍数据bookData 由 chapters-by-part 等逐步填充,不再预加载 all-chapters
bookData: null,
totalSections: 90,
// 购买记录
purchasedSections: [],
hasFullBook: false,
// VIP 会员365天包含增值版免费与 hasFullBook=9.9 买断不同)
isVip: false,
vipExpireDate: '',
// 已读章节(仅统计有权限打开过的章节,用于首页「已读/待读」)
readSectionIds: [],
// 推荐绑定
pendingReferralCode: null, // 待绑定的推荐码
// 客服微信号(从系统配置加载,默认值兜底)
serviceWechat: '28533368',
// 主题配置
theme: {
brandColor: '#00CED1',
brandSecondary: '#20B2AA',
goldColor: '#FFD700',
bgColor: '#000000',
cardBg: '#1c1c1e'
},
// 系统信息
systemInfo: null,
statusBarHeight: 44,
navBarHeight: 88,
2026-03-18 17:54:32 +08:00
capsuleRightPadding: 96, // 胶囊右侧留白(px)getSystemInfo 会按 menuButton 计算
// TabBar相关
currentTab: 0,
// 是否处于「单页模式」(如朋友圈文章里的单页预览)
// 用于在受限环境下给出引导文案,提示用户点击底部「前往小程序」进入完整体验
isSinglePageMode: false,
// 更新检测:上次检测时间戳,避免频繁请求
2026-03-17 18:22:06 +08:00
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 短节流(避免与 launch 重复打满接口)
lastVipContactCheck: 0,
// 头像昵称检测:上次检测时间戳(与 VIP 检测同周期刷新)
lastAvatarNicknameCheck: 0,
/** MBTI → 默认头像 URL/api/miniprogram/config/mbti-avatars供推广海报等 */
mbtiAvatarsMap: {},
mbtiAvatarsExpires: 0,
},
/** 正式版强制生产 API避免误传 localhost 导致审核/线上全挂 */
initApiBaseUrl() {
const KEY = 'apiBaseUrl'
try {
const info = wx.getAccountInfoSync?.()
const env = info?.miniProgram?.envVersion || 'release'
if (env === 'release') {
this.globalData.baseUrl = PRODUCTION_BASE_URL
try {
const saved = wx.getStorageSync(KEY)
if (saved && saved !== PRODUCTION_BASE_URL) wx.removeStorageSync(KEY)
} catch (_) {}
return
}
const saved = wx.getStorageSync(KEY)
if (saved && typeof saved === 'string' && /^https?:\/\//.test(saved)) {
this.globalData.baseUrl = String(saved).replace(/\/$/, '')
} else {
this.globalData.baseUrl = PRODUCTION_BASE_URL
}
} catch (_) {
this.globalData.baseUrl = PRODUCTION_BASE_URL
}
},
onLaunch(options) {
this.initApiBaseUrl()
// 昵称等隐私组件需先授权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()
2026-03-17 14:02:09 +08:00
// 加载 mpConfigappId、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 秒
2026-03-17 18:22:06 +08:00
const now = Date.now()
if (!this.globalData.lastMpConfigCheck || now - this.globalData.lastMpConfigCheck > 30 * 1000) {
this.globalData.lastMpConfigCheck = now
this.getAuditMode()
2026-03-17 18:22:06 +08:00
}
// 从后台切回:刷新 VIP/头像引导vipGuideThrottleMs=0 表示不限制间隔)
const vipGuideThrottleMs = 0
if (this.globalData.isLoggedIn && this.globalData.userInfo?.id) {
if (vipGuideThrottleMs <= 0 || !this.globalData.lastVipContactCheck || now - this.globalData.lastVipContactCheck > vipGuideThrottleMs) {
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
}
2026-03-18 17:54:32 +08:00
// 计算导航栏高度与胶囊避让
const menuButton = wx.getMenuButtonBoundingClientRect()
if (menuButton) {
this.globalData.navBarHeight = (menuButton.top - systemInfo.statusBarHeight) * 2 + menuButton.height + systemInfo.statusBarHeight
2026-03-18 17:54:32 +08:00
// 胶囊右侧留白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 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: 'VIP 名片与派对场景会展示头像与昵称,补全后对方更容易认出你。',
confirmText: '去填写',
showCancel: false,
success: () => {
wx.redirectTo({ url: '/pages/profile-edit/profile-edit' })
}
})
return
}
if (phone && wechatId) return
// VIP 无手机号:弹窗说明后跳转
if (!phone) {
wx.showModal({
title: '补全手机号',
content: '手机号用于找伙伴、提现验证与重要通知,仅本人可见。',
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<object|null>} 完整 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
}
if (!forceRefresh && !this.globalData.configCache) {
try {
const local = wx.getStorageSync(CONFIG_CACHE_KEY)
const exp = Number(local && local.expiresAt)
if (local && local.data && exp > now) {
this.globalData.configCache = local.data
this.globalData.configCacheExpires = exp
return local.data
}
} catch (_) {}
}
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
try {
wx.setStorageSync(CONFIG_CACHE_KEY, {
data: res,
expiresAt: this.globalData.configCacheExpires
})
} catch (_) {}
return res
}
} catch (_) {}
try {
const legacy = await this.request({ url: '/api/miniprogram/config', silent: true, timeout: 5000 })
if (legacy) {
const cfg = (legacy.configs && legacy.configs.mp_config) || legacy.mpConfig || {}
const res = {
success: legacy.success !== false,
prices: legacy.prices || (legacy.configs && legacy.configs.chapter_config && legacy.configs.chapter_config.prices) || {},
features: legacy.features || (legacy.configs && legacy.configs.feature_config) || {},
userDiscount: legacy.userDiscount,
mpConfig: cfg,
configs: legacy.configs || {}
}
this.globalData.configCache = res
this.globalData.configCacheExpires = now + CACHE_TTL
try {
wx.setStorageSync(CONFIG_CACHE_KEY, {
data: res,
expiresAt: this.globalData.configCacheExpires
})
} catch (_) {}
return res
}
} catch (_) {}
if (this.globalData.configCache) return this.globalData.configCache
try {
const local = wx.getStorageSync(CONFIG_CACHE_KEY)
if (local && local.data) return local.data
} catch (_) {}
return null
},
/**
* 获取阅读页扩展配置linkTagslinkedMiniprograms懒加载
*/
async getReadExtras() {
if (this.globalData.readExtrasCacheValid) {
return {
linkTags: this.globalData.linkTagsConfig || [],
linkedMiniprograms: this.globalData.linkedMiniprograms || [],
mentionPersons: this.globalData.mentionPersons || [],
}
}
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
if (Array.isArray(res.mentionPersons)) this.globalData.mentionPersons = res.mentionPersons
else this.globalData.mentionPersons = []
this.globalData.readExtrasCacheValid = true
return res
}
} catch (e) {}
if (!Array.isArray(this.globalData.mentionPersons)) this.globalData.mentionPersons = []
return {
linkTags: this.globalData.linkTagsConfig || [],
linkedMiniprograms: this.globalData.linkedMiniprograms || [],
mentionPersons: this.globalData.mentionPersons,
}
},
/**
* 仅刷新审核模式从后台切回时用轻量
*/
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
},
2026-03-18 12:40:51 +08:00
// 加载 mpConfigappId、mchId、withdrawSubscribeTmplId、auditMode、supportWechat 等),失败时保留 globalData 默认值
2026-03-17 14:02:09 +08:00
async loadMpConfig() {
try {
const res = await this.getConfig()
if (res) {
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 || ''
}
2026-03-17 14:02:09 +08:00
}
} catch (e) {
console.warn('[App] loadMpConfig 失败,使用默认值:', e?.message || e)
}
// 审核模式不走 5min 本地 config 缓存:始终以独立接口为准,避免后台已开审核端仍显示支付入口
try {
await this.getAuditMode()
} catch (_) {}
this.loadMbtiAvatarsMap()
},
/** 拉取后台配置的 16 型 MBTI 默认头像(公开接口,约 5 分钟本地缓存) */
async loadMbtiAvatarsMap() {
try {
const now = Date.now()
if (this.globalData.mbtiAvatarsExpires && this.globalData.mbtiAvatarsExpires > now) return
const res = await this.request({
url: '/api/miniprogram/config/mbti-avatars',
silent: true,
timeout: 8000,
})
if (res && res.success && res.avatars && typeof res.avatars === 'object') {
this.globalData.mbtiAvatarsMap = res.avatars
this.globalData.mbtiAvatarsExpires = now + 5 * 60 * 1000
}
} catch (e) {
console.warn('[App] loadMbtiAvatarsMap:', e?.message || e)
}
},
/** 展示用头像:优先用户头像,否则 MBTI 映射(需已 loadMbtiAvatarsMap */
resolveAvatarWithMbti(avatar, mbti) {
try {
const { resolveAvatarWithMbti } = require('./utils/mbtiAvatar.js')
return resolveAvatarWithMbti(
avatar,
mbti,
this.globalData.mbtiAvatarsMap || {},
this.globalData.baseUrl || ''
)
} catch (_) {
return (avatar && String(avatar).trim()) || ''
}
2026-03-17 14:02:09 +08:00
},
/**
* 小程序更新检测基于 wx.getUpdateManager
* - 启动时检测从后台切回前台时也检测短间隔即可避免用户感知很久才检查更新
*/
checkUpdate() {
try {
if (!wx.canIUse('getUpdateManager')) return
const now = Date.now()
const lastCheck = this.globalData.lastUpdateCheck || 0
if (lastCheck && now - lastCheck < 60 * 1000) return // 1 分钟内不重复检测
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
},
_shouldFallbackToProduction(err) {
const msg = String((err && err.errMsg) || (err && err.message) || '').toLowerCase()
return (
msg.includes('connection_failed') ||
msg.includes('err_proxy_connection_failed') ||
msg.includes('dns') ||
msg.includes('name not resolved') ||
msg.includes('failed to fetch') ||
msg.includes('econnrefused') ||
msg.includes('network') ||
msg.includes('timeout')
)
},
_switchBaseUrlToProduction() {
this.globalData.baseUrl = PRODUCTION_BASE_URL
try {
wx.setStorageSync('apiBaseUrl', PRODUCTION_BASE_URL)
} catch (_) {}
},
_requestOnce(url, options = {}, silent = false) {
const showError = (msg) => {
if (!silent && msg) wx.showToast({ title: msg, icon: 'none', duration: 2500 })
}
return new Promise((resolve, reject) => {
const token = wx.getStorageSync('token')
wx.request({
url: this.globalData.baseUrl + url,
method: options.method || 'GET',
data: options.data || {},
2026-03-17 18:22:06 +08:00
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) {
if (data && data.success === false) {
const msg = this._getApiErrorMsg(data, '操作失败')
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
}
const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败')
showError(msg)
reject(new Error(msg))
},
fail: (err) => {
const msg = (err && err.errMsg)
? (String(err.errMsg).indexOf('timeout') !== -1 ? '请求超时,请重试' : '网络异常,请重试')
: '网络异常,请重试'
showError(msg)
reject(err || new Error(msg))
}
})
})
},
/**
* 统一请求方法接口失败时会弹窗提示 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
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 = this._requestOnce(url, options, silent).catch(async (err) => {
const currentBase = String(this.globalData.baseUrl || '').replace(/\/$/, '')
if (currentBase !== PRODUCTION_BASE_URL && this._shouldFallbackToProduction(err)) {
this._switchBaseUrlToProduction()
return this._requestOnce(url, options, silent)
}
const msg = (err && err.message) ? err.message : '网络异常,请重试'
throw 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
},
// 手机号登录:需同时传 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)
},
/**
* 记录最近打开过某章节含仅预览/未登录我的最近阅读展示
* 不写入 readSectionIds避免把点开预览算进已读章节数
*/
touchRecentSection(sectionId) {
if (!sectionId) return
try {
let list = wx.getStorageSync('recent_section_opens')
if (!Array.isArray(list)) list = []
const now = Date.now()
list = list.filter((x) => x && x.id !== sectionId)
list.unshift({ id: String(sectionId), t: now })
wx.setStorageSync('recent_section_opens', list.slice(0, 40))
} catch (_) {}
},
// 已读章节数(用于首页展示)
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()
}
})