// app.js - MBTI小程序主入口 const { request } = require('./utils/request.js') const { isAuditHideAiMode } = require('./utils/miniprogramAuditGate.js') // 全局注入:所有页面 onShow 自动上报 page_view;onShareAppMessage 自动上报 share const _OrigPage = Page Page = function (pageConfig) { const origOnShow = pageConfig.onShow pageConfig.onShow = function () { try { const { reportPageView } = require('./utils/analytics.js') reportPageView() } catch (e) {} if (typeof origOnShow === 'function') { origOnShow.call(this) } } if (typeof pageConfig.onShareAppMessage === 'function') { const origShare = pageConfig.onShareAppMessage pageConfig.onShareAppMessage = function (res) { try { require('./utils/analytics.js').reportShare('friend') } catch (e) {} return origShare.call(this, res) } } if (typeof pageConfig.onShareTimeline === 'function') { const origTimeline = pageConfig.onShareTimeline pageConfig.onShareTimeline = function () { try { require('./utils/analytics.js').reportShare('timeline') } catch (e) {} return origTimeline.call(this) } } return _OrigPage(pageConfig) } App({ globalData: { userInfo: null, openId: null, token: null, siteTitle: '神仙团队AI性格测试', textConfig: null, // 从 /api/config/runtime 动态加载:analyzingTitle, startButtonText, reportTitle, aiAnalysisText 等 maintenanceMode: undefined, // 审核模式,undefined=未加载,等 getRuntimeConfig 后再决定,避免 tabBar 闪烁 /** 小程序提审:隐藏神仙 AI Tab/入口,由 runtime.miniprogramAuditMode 写入 */ miniprogramAuditMode: false, reviewMode: undefined, // 面相审核开关,与 camera/index 一致,由 runtime.reviewMode 写入 // GET /api/config/runtime 内嵌的快捷问句(网关拦截独立接口时兜底) runtimeAiQuickQuestions: null, /** 仅当 /api/config/runtime 返回 data.aiChat 时写入;用于区分「runtime 已发版 / 对话接口未发版」 */ aiChatRuntime: null, // GET /api/config/runtime 内嵌的深度服务类目(与 /api/config/deep-pricing 同源) deepPricingPersonal: null, deepPricingEnterprise: null, // 当前使用范围:personal 个人版 / enterprise 企业版(影响定价与 enterpriseId 写入) appScope: 'personal', // 扫码进入企业页时 scene 解析出的企业ID(e_123),提交测试/分析时优先使用 enterpriseIdFromScene: null, // 超管配置的默认企业 ID(无 scene/eid 等入口参数时回落) defaultEnterpriseId: null, // API 基础地址:默认走线上;本机/内网调试可在开发者工具执行 // wx.setStorageSync('apiBaseOverride', 'https://你的调试域名') 后重启小程序 //apiBase: 'https://mbtiapi.quwanzhi.com', apiBase: 'http://mbti.com', // VIP信息 vipInfo: null, // 测试次数 testCount: 0, // 已解锁的测试 unlockedTests: [], // 测试结果缓存 mbtiResult: null, sbtiResult: null, discResult: null, pdpResult: null, aiResult: null, // 企业功能权限(超管在企业管理中配置):null=无限制(个人版),对象时按 key 控制 enterprisePermissions: null }, onLaunch(launchOptions) { // 记录场景值供埋点使用 this.globalData.scene = launchOptions && launchOptions.scene ? launchOptions.scene : '' try { const ov = wx.getStorageSync('apiBaseOverride') if (ov && typeof ov === 'string' && ov.trim()) { this.globalData.apiBase = ov.trim().replace(/\/$/, '') } } catch (e) {} try { const { mergeThirdPartyFromQuery } = require('./utils/thirdPartyContext.js') mergeThirdPartyFromQuery((launchOptions && launchOptions.query) || {}) } catch (e) {} // 加载本地存储的数据 this.loadStoredData() // 上报应用启动事件 try { const analytics = require('./utils/analytics.js') analytics.reportAppLaunch(launchOptions) } catch (e) {} // 先读取缓存的 TabBar 配置,避免首屏 flash(后续拉接口再刷新) try { const cachedTabBar = wx.getStorageSync('tabBarConfig') if (cachedTabBar && Array.isArray(cachedTabBar.items) && cachedTabBar.items.length >= 2) { this.globalData.tabBar = cachedTabBar } } catch (e) {} // 必须先完成静默登录再拉 runtime:否则无 token 时 enterprisePermissions 永远 null,清除缓存后必现「要二次刷新」 this.silentLogin() .catch(() => {}) .then(() => this.getRuntimeConfig()) .then(() => { this._afterRuntimeSynced() }) .catch(() => {}) // 并行拉取 TabBar 动态配置(不阻塞主流程) this.loadTabBarConfig().catch(() => {}) }, /** * 拉取小程序 TabBar 动态配置(后台可调整顺序/显示) * 写入 globalData.tabBar 并缓存到本地 */ loadTabBarConfig() { return new Promise((resolve) => { const base = (this.globalData.apiBase || '').replace(/\/$/, '') if (!base) { resolve(null); return } const apply = (res) => { if (res && res.statusCode === 200 && res.data && res.data.code === 200 && res.data.data) { const cfg = res.data.data if (Array.isArray(cfg.items) && cfg.items.length >= 2) { this.globalData.tabBar = cfg try { wx.setStorageSync('tabBarConfig', cfg) } catch (e) {} this.refreshCustomTabBar() return true } } return false } // 线上部分网关对 /api/mp/tabbar 返回 404;与 runtime 相同前缀的 config 常已放行 const urls = [base + '/api/config/mp-tabbar', base + '/api/mp/tabbar'] const tryNext = (i) => { if (i >= urls.length) { resolve(null) return } wx.request({ url: urls[i], method: 'GET', timeout: 6000, success: (res) => { if (apply(res)) resolve(null) else tryNext(i + 1) }, fail: () => tryNext(i + 1) }) } tryNext(0) }) }, /** 审核开关或 Tab 配置变化后刷新自定义 tabBar(与 getRuntimeConfig / loadTabBarConfig 共用) */ refreshCustomTabBar() { try { const pages = getCurrentPages() if (!pages || pages.length === 0) return const top = pages[pages.length - 1] if (top && typeof top.getTabBar === 'function') { const tb = top.getTabBar() if (tb && typeof tb.refreshFromConfig === 'function') tb.refreshFromConfig() } } catch (e) {} }, /** * runtime 写入 globalData 后,同步当前栈顶页面与 tabBar(避免首屏用旧 null 权限) */ _afterRuntimeSynced() { try { const pages = getCurrentPages() if (!pages || pages.length === 0) return const top = pages[pages.length - 1] if (!top) return const gd = this.globalData const ep = gd.enterprisePermissions const audit = !!(gd.reviewMode || gd.maintenanceMode) const permFace = !ep || ep.face !== false if (typeof top.getTabBar === 'function') { const tb = top.getTabBar() if (tb && typeof tb.refreshFromConfig === 'function') tb.refreshFromConfig() else if (tb && typeof tb.updateSelected === 'function') tb.updateSelected() } const route = top.route || '' if (isAuditHideAiMode(gd) && route.indexOf('pages/ai-chat/') === 0) { wx.switchTab({ url: '/pages/index/index' }) return } if (route === 'pages/index/index' && typeof top.setData === 'function') { top.setData({ reviewMode: audit, permFace, siteTitle: audit ? String(gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'), startButtonText: (audit || (ep && ep.face === false)) ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '30秒测出你的性格'), aiAnalysisText: audit ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析') }) } else if (route === 'pages/profile/index' && typeof top.setData === 'function') { if (typeof top._syncPerms === 'function') top._syncPerms.call(top) top.setData({ reviewMode: audit }) if (typeof top.getTabBar === 'function') { const tb = top.getTabBar() if (tb && typeof tb.updateSelected === 'function') tb.updateSelected() } } else if (route === 'pages/enterprise/index' && typeof top.setData === 'function') { const maintenanceMode = audit const pf = permFace top.setData({ maintenanceMode, reviewMode: maintenanceMode, permFace: pf, siteTitle: gd.siteTitle || '神仙团队AI性格测试', startButtonEnterprise: (maintenanceMode || !pf) ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonEnterprise) || '开始面部测试'), aiAnalysisText: (gd.textConfig && gd.textConfig.aiAnalysisText) || '智能分析' }) if (typeof top.getTabBar === 'function') { const tb = top.getTabBar() if (tb && typeof tb.updateSelected === 'function') tb.updateSelected() } } else if (route === 'pages/index/camera' && typeof top.setData === 'function') { top.setData({ reviewMode: audit }) if (ep && ep.face === false) { wx.navigateTo({ url: '/pages/test-select/index' }) } if (typeof top.getTabBar === 'function') { const tb = top.getTabBar() if (tb && typeof tb.updateSelected === 'function') tb.updateSelected() } } } catch (e) { console.error('_afterRuntimeSynced', e) } }, onShow() { try { const { reportPageView } = require('./utils/analytics.js') reportPageView() } catch (e) {} }, onHide() { try { const { flush } = require('./utils/analytics.js') flush() } catch (e) {} }, // 加载本地存储数据 loadStoredData() { const token = wx.getStorageSync('token') if (token) { this.globalData.token = token } const userInfo = wx.getStorageSync('userInfo') if (userInfo) { this.globalData.userInfo = userInfo // 优先使用后端返回的 openid 字段,而不是内部自增 id this.globalData.openId = userInfo.openid || userInfo.openId || null } // VIP信息 const vipInfo = wx.getStorageSync('vipInfo') if (vipInfo) { this.globalData.vipInfo = vipInfo } // 测试次数 const testCount = wx.getStorageSync('testCount') if (testCount) { this.globalData.testCount = testCount } // 已解锁测试 const unlockedTests = wx.getStorageSync('unlockedTests') if (unlockedTests) { this.globalData.unlockedTests = unlockedTests } // 测试结果 this.globalData.mbtiResult = wx.getStorageSync('mbtiResult') || null this.globalData.sbtiResult = wx.getStorageSync('sbtiResult') || null this.globalData.discResult = wx.getStorageSync('discResult') || null this.globalData.pdpResult = wx.getStorageSync('pdpResult') || null this.globalData.aiResult = wx.getStorageSync('aiResult') || null }, /** * 静默登录:wx.login 取 code,请求后端换 token 与用户信息 * @returns {Promise} 是否登录成功(拿到 token) */ silentLogin() { return new Promise((resolve) => { wx.login({ success: (res) => { if (!res.code) { resolve(false) return } const url = `${this.globalData.apiBase}/api/auth/wechat` const loginData = { code: res.code } try { const { getEffectiveEnterpriseId } = require('./utils/enterpriseContext.js') const eid = getEffectiveEnterpriseId() if (eid) loginData.enterpriseId = eid } catch (e) {} try { const { getThirdPartyForLoginBody } = require('./utils/thirdPartyContext.js') Object.assign(loginData, getThirdPartyForLoginBody()) } catch (e) {} wx.request({ url, method: 'POST', timeout: 25000, header: { 'Content-Type': 'application/json' }, data: loginData, success: (response) => { if (response.statusCode === 200 && response.data && response.data.code === 409 && response.data.message) { try { wx.showToast({ title: String(response.data.message), icon: 'none', duration: 3000 }) } catch (e) {} } if (response.statusCode === 200 && response.data && response.data.code === 200) { const data = response.data.data || {} const { token, user } = data if (token) { this.globalData.token = token wx.setStorageSync('token', token) } if (user) { try { const { syncPhoneLoginStorageWithUser } = require('./utils/phoneAuth.js') syncPhoneLoginStorageWithUser(user) } catch (e) {} this.globalData.userInfo = user this.globalData.openId = user.openid || user.openId || null wx.setStorageSync('userInfo', user) } if (token) { this._tryDistributionBind() try { require('./utils/analytics.js').track('login_silent_success', { userId: user && user.id, hasEnterprise: !!(user && user.enterpriseId), invited: !!(this.globalData._pendingInviterId) }) } catch (e) {} } resolve(!!token) return } try { require('./utils/analytics.js').track('login_silent_fail', { reason: 'non_200' }) } catch (e) {} resolve(false) }, fail: (err) => { console.error('登录请求失败:', err) try { require('./utils/analytics.js').track('login_silent_fail', { reason: 'network' }) } catch (e) {} const storedToken = wx.getStorageSync('token') const storedUser = wx.getStorageSync('userInfo') if (storedToken) { this.globalData.token = storedToken this.globalData.userInfo = storedUser || null this.globalData.openId = storedUser ? (storedUser.openid || storedUser.openId || null) : null resolve(true) } else { resolve(false) } } }) }, fail: () => { try { require('./utils/analytics.js').track('login_silent_fail', { reason: 'wx_login' }) } catch (e) {} resolve(false) } }) }) }, /** * 确保已登录:有 token 直接 resolve,否则先执行静默登录 * 加 15s 超时兜底:若 wx.login / wx.request 卡住,强制 resolve(false),避免调用方 promise 悬挂 * @returns {Promise} 当前是否有有效登录态 */ ensureLogin() { if (this.globalData.token) return Promise.resolve(true) try { const st = wx.getStorageSync('token') if (st) { this.globalData.token = st const su = wx.getStorageSync('userInfo') if (su) { this.globalData.userInfo = su this.globalData.openId = su.openid || su.openId || this.globalData.openId } return Promise.resolve(true) } } catch (e) {} return new Promise((resolve) => { let settled = false const timer = setTimeout(() => { if (settled) return settled = true try { require('./utils/analytics.js').track('login_silent_fail', { reason: 'timeout' }) } catch (e) {} resolve(false) }, 15000) this.silentLogin() .then((ok) => { if (settled) return settled = true clearTimeout(timer) if (!ok) { try { const st = wx.getStorageSync('token') if (st) { this.globalData.token = st const su = wx.getStorageSync('userInfo') if (su) { this.globalData.userInfo = su this.globalData.openId = su.openid || su.openId || this.globalData.openId } resolve(true) return } } catch (e) {} } resolve(!!ok) }) .catch(() => { if (settled) return settled = true clearTimeout(timer) try { const st = wx.getStorageSync('token') if (st) { this.globalData.token = st resolve(true) return } } catch (e) {} resolve(false) }) }) }, /** * 登录成功后尝试分销绑定(处理进入时携带的 uid 参数) */ _tryDistributionBind() { const inviterId = this.globalData._pendingInviterId const scope = this.globalData._pendingInviterScope || 'personal' const eid = this.globalData._pendingInviterEid || null if (!inviterId || inviterId <= 0) return // 清除 pending 状态,避免重复绑定 this.globalData._pendingInviterId = null this.globalData._pendingInviterScope = null this.globalData._pendingInviterEid = null const token = this.globalData.token if (!token) return const data = { inviterId, scope } if (eid) data.eid = eid request({ url: '/api/distribution/bind', method: 'POST', data, success: () => {}, fail: () => {} }) }, /** 清除登录态(退出登录时调用) */ logout() { try { const { clearPhoneLoginSession } = require('./utils/phoneAuth.js') clearPhoneLoginSession() } catch (e) {} this.globalData.token = null this.globalData.userInfo = null this.globalData.openId = null wx.removeStorageSync('token') wx.removeStorageSync('userInfo') }, // 获取用户信息(弹窗授权),并同步昵称/头像到后端 getUserInfo(callback) { wx.getUserProfile({ desc: '用于展示用户头像和昵称', success: (res) => { const u = res.userInfo this.globalData.userInfo = { ...this.globalData.userInfo, ...u, avatarUrl: u.avatarUrl || u.avatar } wx.setStorageSync('userInfo', this.globalData.userInfo) callback && callback(this.globalData.userInfo) this.syncProfileToServer({ nickname: u.nickName, avatar: u.avatarUrl || u.avatar }) }, fail: () => { callback && callback(this.globalData.userInfo || null) } }) }, // 同步昵称、头像到后端(需已登录) syncProfileToServer(profile, callback) { if (!this.globalData.token || !profile) { callback && callback(false) return } // 记录同步请求(调试用) console.log('同步用户资料到服务器:', profile) request({ url: '/api/auth/wechat/profile', method: 'PUT', data: profile, success: (res) => { console.log('同步用户资料响应:', res) if (res.statusCode === 200 && res.data && res.data.code === 200) { // 如果服务器返回了更新后的数据,使用服务器数据 if (res.data.data) { const updatedUserInfo = { ...this.globalData.userInfo, ...res.data.data } // 确保 nickname 和 nickName 字段都更新 if (profile.nickname) { updatedUserInfo.nickname = profile.nickname updatedUserInfo.nickName = profile.nickname } if (profile.avatar) { updatedUserInfo.avatar = profile.avatar updatedUserInfo.avatarUrl = profile.avatar } if (profile.birthday !== undefined) { updatedUserInfo.birthday = profile.birthday } this.globalData.userInfo = updatedUserInfo wx.setStorageSync('userInfo', updatedUserInfo) console.log('用户资料已同步:', updatedUserInfo) } else { // 服务器没有返回数据,使用本地更新的数据 const updatedUserInfo = { ...this.globalData.userInfo, ...profile } if (profile.nickname) { updatedUserInfo.nickname = profile.nickname updatedUserInfo.nickName = profile.nickname } if (profile.avatar) { updatedUserInfo.avatar = profile.avatar updatedUserInfo.avatarUrl = profile.avatar } if (profile.birthday !== undefined) { updatedUserInfo.birthday = profile.birthday } this.globalData.userInfo = updatedUserInfo wx.setStorageSync('userInfo', updatedUserInfo) console.log('用户资料已更新(本地):', updatedUserInfo) } callback && callback(true) } else { console.error('更新用户资料失败:', res.data) callback && callback(false) } }, fail: (err) => { console.error('请求失败:', err) callback && callback(false) } }) }, // 保存测试结果(同步服务端后 resolve { id, testType },用于结果页 URL 与分享) saveTestResult(type, result) { const { getEnterpriseIdForApiPayload } = require('./utils/enterpriseContext.js') const { triggerTestResultCompleted } = require('./utils/pushHook.js') const key = `${type}Result` wx.setStorageSync(key, result) this.globalData[key] = result if (!this.globalData.token) { return Promise.resolve({}) } const enterpriseId = getEnterpriseIdForApiPayload() return new Promise((resolve) => { wx.request({ url: `${this.globalData.apiBase}/api/test/submit`, method: 'POST', header: { 'Authorization': `Bearer ${this.globalData.token}`, 'Content-Type': 'application/json' }, data: { testType: type, answers: result.answers || [], result: result, userId: this.globalData.userInfo?.id ?? this.globalData.openId, enterpriseId: enterpriseId != null ? enterpriseId : undefined, testDuration: result.testDuration || 0, timestamp: new Date().toISOString() }, success: (res) => { if (res.statusCode === 200 && res.data && res.data.code === 200 && res.data.data && typeof res.data.data === 'object') { const extra = res.data.data if (extra && extra.id) { triggerTestResultCompleted(extra.id) } resolve(extra) } else { resolve({}) } }, fail: () => resolve({}) }) }) }, // 获取测试结果 getTestResult(type) { return this.globalData[`${type}Result`] || wx.getStorageSync(`${type}Result`) || null }, /** * 获取运行配置:个人/企业定价 + 当前 AI 服务商(超管配置,默认第一个启用的) * 有 token 且用户属于企业则返回企业定价,否则个人定价 * @returns {Promise<{pricingType, pricing, aiProviderId, aiProviderName}>} */ getRuntimeConfig() { const reqId = (this._runtimeReqSeq = (this._runtimeReqSeq || 0) + 1) return new Promise((resolve, reject) => { const scope = this.globalData.appScope || 'personal' const base = this.globalData.apiBase.replace(/\/$/, '') const url = `${base}/api/config/runtime?scope=${encodeURIComponent(scope)}` const token = this.globalData.token || wx.getStorageSync('token') || '' wx.request({ url, method: 'GET', header: token ? { Authorization: 'Bearer ' + token } : {}, success: (res) => { if (reqId !== this._runtimeReqSeq) { resolve(null) return } if (res.statusCode === 200 && res.data && res.data.code === 200) { const data = res.data.data || {} if (data.siteTitle) this.globalData.siteTitle = data.siteTitle if (data.textConfig) this.globalData.textConfig = data.textConfig if (data.maintenanceMode !== undefined) this.globalData.maintenanceMode = !!data.maintenanceMode if (data.miniprogramAuditMode !== undefined) { this.globalData.miniprogramAuditMode = !!data.miniprogramAuditMode } else { this.globalData.miniprogramAuditMode = false } if (data.reviewMode !== undefined) { this.globalData.reviewMode = !!data.reviewMode } else if (data.maintenanceMode !== undefined) { this.globalData.reviewMode = !!data.maintenanceMode } if (data.defaultEnterpriseId != null && Number(data.defaultEnterpriseId) > 0) { this.globalData.defaultEnterpriseId = Number(data.defaultEnterpriseId) } else { this.globalData.defaultEnterpriseId = null } if (data.enterprisePermissions && typeof data.enterprisePermissions === 'object') { this.globalData.enterprisePermissions = data.enterprisePermissions } else { this.globalData.enterprisePermissions = null } if (data.tabBar && Array.isArray(data.tabBar.items) && data.tabBar.items.length >= 2) { this.globalData.tabBar = data.tabBar try { wx.setStorageSync('tabBarConfig', data.tabBar) } catch (e) {} } try { const { applyAuditUiOverride } = require('./utils/miniprogramAuditGate.js') applyAuditUiOverride(this) } catch (e) {} this.refreshCustomTabBar() if (data.aiQuickQuestions && typeof data.aiQuickQuestions === 'object') { this.globalData.runtimeAiQuickQuestions = data.aiQuickQuestions } else { this.globalData.runtimeAiQuickQuestions = null } if (data.aiChat && typeof data.aiChat === 'object') { this.globalData.aiChatRuntime = data.aiChat } else { this.globalData.aiChatRuntime = null } if (data.deepPricingPersonal && Array.isArray(data.deepPricingPersonal.categories)) { this.globalData.deepPricingPersonal = data.deepPricingPersonal.categories } else { this.globalData.deepPricingPersonal = null } if (data.deepPricingEnterprise && Array.isArray(data.deepPricingEnterprise.categories)) { this.globalData.deepPricingEnterprise = data.deepPricingEnterprise.categories } else { this.globalData.deepPricingEnterprise = null } resolve(data) } else { reject(new Error(res.data && res.data.message ? res.data.message : '获取配置失败')) } }, fail: reject }) }) } })