/** * 卡若创业派对 - 用户旅程规则引擎 * 从后端 /api/miniprogram/user-rules 读取启用的规则,按场景触发提示(文案偏利他、少用命令式) * 稳定版兼容:readCount 用 getReadCount(),hasPurchasedFull 用 hasFullBook,完善头像跳 avatar-nickname * * trigger → scene 映射: * 注册 → after_login(头像或昵称未完善) * update_avatar / 完善头像 → 仅头像未完善 * update_nickname / 修改昵称 → 仅昵称为默认 * 点击收费章节 → before_read * 完成匹配 → after_match * 完成付款 → after_pay * 累计浏览5章节 → page_show * 加入派对房 → before_join_party * 绑定微信 → after_bindwechat * 收益满50元 → earnings_check * 手动触发 → manual * 浏览导师页 → browse_mentor */ function getAppInstance() { try { const a = getApp() return a && a.globalData ? a : null } catch (e) { return null } } const RULE_COOLDOWN_KEY = 'rule_engine_cooldown' // 0 = 关闭冷却(需求:去掉「操作频繁 / N 分钟」类体感限制) const COOLDOWN_MS = 0 let _cachedRules = null let _cacheTs = 0 const CACHE_TTL = 5 * 60 * 1000 const TRIGGER_SCENE_MAP = { '注册': 'after_login', '完善头像': 'after_login', '修改昵称': 'after_login', '点击收费章节': 'before_read', '完成匹配': 'after_match', '完成付款': 'after_pay', '发起支付': 'before_pay', '累计浏览5章节': 'page_show', '加入派对房': 'before_join_party', '绑定微信': 'after_bindwechat', '收益满50元': 'earnings_check', '手动触发': 'manual', '浏览导师页': 'browse_mentor', } // 与后台「规则类型 trigger」一致:支持英文 key 与同义中文(逻辑在 isRuleEnabled 定义之后) const TRIGGER_KEYS_AVATAR = ['update_avatar', '完善头像'] const TRIGGER_KEYS_NICKNAME = ['update_nickname', '修改昵称'] function isInCooldown(ruleId) { if (!COOLDOWN_MS || COOLDOWN_MS <= 0) return false try { const map = wx.getStorageSync(RULE_COOLDOWN_KEY) || {} const ts = map[ruleId] if (!ts) return false return Date.now() - ts < COOLDOWN_MS } catch (e) { console.warn('[RuleEngine] 读取冷却状态失败:', e) return false } } function setCooldown(ruleId) { if (!COOLDOWN_MS || COOLDOWN_MS <= 0) return try { const map = wx.getStorageSync(RULE_COOLDOWN_KEY) || {} map[ruleId] = Date.now() wx.setStorageSync(RULE_COOLDOWN_KEY, map) } catch (e) { console.warn('[RuleEngine] 写入冷却状态失败:', e) } } function getUserInfo() { const app = getAppInstance() return app ? (app.globalData.userInfo || {}) : {} } function trimStr(v) { if (v == null || v === undefined) return '' const s = String(v).trim() return s } /** 合并服务端 profile,避免本地 userInfo 未同步导致「已填写仍弹窗」 */ async function fetchProfileMergeUser() { const base = { ...getUserInfo() } const userId = base.id if (!userId) return base try { const app = getAppInstance() const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true }) if (res?.success && res.data) { const d = res.data return { ...base, mbti: d.mbti != null ? d.mbti : base.mbti, industry: d.industry != null ? d.industry : base.industry, position: d.position != null ? d.position : base.position, projectIntro: d.projectIntro || d.project_intro || base.projectIntro, phone: d.phone != null ? d.phone : base.phone, wechatId: d.wechatId || d.wechat_id || base.wechatId, } } } catch (e) {} return base } async function loadRules() { if (_cachedRules && Date.now() - _cacheTs < CACHE_TTL) return _cachedRules const app = getAppInstance() if (!app) return _cachedRules || [] const userId = (app.globalData.userInfo || {}).id || '' try { const url = userId ? `/api/miniprogram/user-rules?userId=${userId}` : '/api/miniprogram/user-rules' const res = await app.request({ url, method: 'GET', silent: true }) if (res && res.success && res.rules) { _cachedRules = res.rules _cacheTs = Date.now() return _cachedRules } } catch (e) { console.warn('[RuleEngine] 加载规则失败,继续使用缓存:', e) } return _cachedRules || [] } function isRuleEnabled(rules, triggerName) { return rules.some(r => r.trigger === triggerName && !r.completed) } function getRuleInfo(rules, triggerName) { return rules.find(r => r.trigger === triggerName && !r.completed) } function isAnyTriggerEnabled(rules, keys) { return keys.some((k) => isRuleEnabled(rules, k)) } function getFirstRuleInfo(rules, keys) { for (let i = 0; i < keys.length; i++) { const info = getRuleInfo(rules, keys[i]) if (info) return info } return null } function isAvatarMissingOrDefault(user) { user = user || getUserInfo() const avatar = user.avatar || user.avatarUrl || '' return !avatar || avatar.includes('default') } function isNicknamePlaceholder(nickname) { const n = trimStr(nickname) return !n || n === '微信用户' || n.startsWith('微信用户') } function markRuleCompleted(ruleId) { const userId = getUserInfo().id if (!userId || !ruleId) return const app = getAppInstance() if (!app) return const numericId = typeof ruleId === 'number' ? ruleId : null if (!numericId) return app.request({ url: '/api/miniprogram/user-rules/complete', method: 'POST', data: { userId, ruleId: numericId }, silent: true }).catch(() => {}) } // 仅头像:trigger = update_avatar 或 完善头像 function checkRule_UpdateAvatar(rules) { if (!isAnyTriggerEnabled(rules, TRIGGER_KEYS_AVATAR)) return null const app = getAppInstance() if (app && app.globalData.isVip) return null const user = getUserInfo() if (!user.id) return null if (!isAvatarMissingOrDefault(user)) return null if (isInCooldown('update_avatar')) return null setCooldown('update_avatar') const info = getFirstRuleInfo(rules, TRIGGER_KEYS_AVATAR) return { ruleId: 'update_avatar', serverRuleId: info?.id, title: info?.title || '上传头像', message: info?.description || '换一张清晰头像,伙伴在名片和匹配里更容易认出你。', confirmText: '去设置', cancelText: '关闭', action: 'navigate', target: '/pages/avatar-nickname/avatar-nickname?focus=avatar' } } // 仅昵称:trigger = update_nickname 或 修改昵称 function checkRule_UpdateNickname(rules) { if (!isAnyTriggerEnabled(rules, TRIGGER_KEYS_NICKNAME)) return null const app = getAppInstance() if (app && app.globalData.isVip) return null const user = getUserInfo() if (!user.id) return null const nickname = user.nickname || user.nickName || '' if (!isNicknamePlaceholder(nickname)) return null if (isInCooldown('update_nickname')) return null setCooldown('update_nickname') const info = getFirstRuleInfo(rules, TRIGGER_KEYS_NICKNAME) return { ruleId: 'update_nickname', serverRuleId: info?.id, title: info?.title || '修改昵称', message: info?.description || '改一个真实好记的昵称,方便伙伴称呼你。', confirmText: '去填写', cancelText: '关闭', action: 'navigate', target: '/pages/avatar-nickname/avatar-nickname?focus=nickname' } } // 稳定版:trigger=注册 时头像或昵称任一未完善则引导(与上面两项拆分配置并存) // VIP 用户不触发:统一由 checkVipContactRequiredAndGuide 跳转 profile-edit,避免与主流程冲突 function checkRule_FillAvatar(rules) { if (!isRuleEnabled(rules, '注册')) return null const app = getAppInstance() if (app && app.globalData.isVip) return null const user = getUserInfo() if (!user.id) return null const nickname = user.nickname || user.nickName || '' if (!isAvatarMissingOrDefault(user) && !isNicknamePlaceholder(nickname)) return null if (isInCooldown('fill_avatar')) return null setCooldown('fill_avatar') const info = getRuleInfo(rules, '注册') const needNick = isNicknamePlaceholder(nickname) const needAv = isAvatarMissingOrDefault(user) const focus = needAv && !needNick ? 'avatar' : needNick && !needAv ? 'nickname' : '' const qs = focus ? `?focus=${focus}` : '' return { ruleId: 'fill_avatar', serverRuleId: info?.id, title: info?.title || '设置头像与昵称', message: info?.description || '头像与昵称会展示在名片与匹配卡片上,方便伙伴认出你。', confirmText: '去设置', cancelText: '关闭', action: 'navigate', target: '/pages/avatar-nickname/avatar-nickname' + qs } } /** 头像/昵称类引导:优先拆条规则(完善头像、修改昵称),其次合并规则(注册) */ function checkAvatarNicknameGuides(rules) { return checkRule_UpdateAvatar(rules) || checkRule_UpdateNickname(rules) || checkRule_FillAvatar(rules) } function checkRule_BindPhone(rules) { if (!isRuleEnabled(rules, '点击收费章节')) return null const user = getUserInfo() if (!user.id) return null if (user.phone) return null if (isInCooldown('bind_phone')) return null setCooldown('bind_phone') const info = getRuleInfo(rules, '点击收费章节') return { ruleId: 'bind_phone', serverRuleId: info?.id, title: info?.title || '绑定手机号', message: info?.description || '绑定后可用于登录验证、收益与重要通知,账户更安全。', confirmText: '去绑定', cancelText: '关闭', action: 'bind_phone', target: null } } function checkRule_FillProfile(rules, user) { if (!isRuleEnabled(rules, '完成匹配')) return null user = user || getUserInfo() if (!user.id) return null const mbti = trimStr(user.mbti) const industry = trimStr(user.industry) const position = trimStr(user.position) if (mbti && industry && position) return null if (isInCooldown('fill_profile')) return null setCooldown('fill_profile') const info = getRuleInfo(rules, '完成匹配') return { ruleId: 'fill_profile', serverRuleId: info?.id, title: info?.title || '补充档案信息', message: info?.description || '补全 MBTI、行业和职位后,匹配页能更准确地向对方展示你,减少无效沟通。', confirmText: '去填写', cancelText: '关闭', action: 'navigate', target: '/pages/profile-edit/profile-edit' } } // 稳定版兼容:readCount 用 getReadCount() function checkRule_ShareAfter5Chapters(rules) { if (!isRuleEnabled(rules, '累计浏览5章节')) return null const user = getUserInfo() if (!user.id) return null const app = getAppInstance() const readCount = app ? (typeof app.getReadCount === 'function' ? app.getReadCount() : (app.globalData.readCount || 0)) : 0 if (readCount < 5) return null if (isInCooldown('share_after_5')) return null setCooldown('share_after_5') const info = getRuleInfo(rules, '累计浏览5章节') return { ruleId: 'share_after_5', serverRuleId: info?.id, title: info?.title || '邀请好友一起看', message: info?.description || '你已阅读 ' + readCount + ' 个章节,好友通过你的分享购买时,你可获得对应分销收益。', confirmText: '查看分享', cancelText: '关闭', action: 'navigate', target: '/pages/referral/referral' } } // 稳定版兼容:hasPurchasedFull 用 hasFullBook function checkRule_FillVipInfo(rules, user) { if (!isRuleEnabled(rules, '完成付款')) return null user = user || getUserInfo() if (!user.id) return null const app = getAppInstance() if (!app || !(app.globalData.hasFullBook || app.globalData.hasPurchasedFull)) return null const wxId = trimStr(user.wechatId || user.wechat_id) const addr = trimStr(user.address) if (wxId && addr) return null if (isInCooldown('fill_vip_info')) return null setCooldown('fill_vip_info') const info = getRuleInfo(rules, '完成付款') return { ruleId: 'fill_vip_info', serverRuleId: info?.id, title: info?.title || '补全 VIP 资料', message: info?.description || '补全微信号与收货地址等信息,便于进入 VIP 群、寄送物料与售后联系。', confirmText: '去填写', cancelText: '关闭', action: 'navigate', target: '/pages/profile-edit/profile-edit' } } function checkRule_JoinParty(rules, user) { if (!isRuleEnabled(rules, '加入派对房')) return null user = user || getUserInfo() if (!user.id) return null if (trimStr(user.projectIntro)) return null if (isInCooldown('join_party')) return null setCooldown('join_party') const info = getRuleInfo(rules, '加入派对房') return { ruleId: 'join_party', serverRuleId: info?.id, title: info?.title || '补充项目介绍', message: info?.description || '用简短文字说明项目与需求,派对房里的伙伴能更快判断是否与你有合作空间。', confirmText: '去填写', cancelText: '关闭', action: 'navigate', target: '/pages/profile-edit/profile-edit' } } function checkRule_BindWechat(rules) { if (!isRuleEnabled(rules, '绑定微信')) return null const user = getUserInfo() if (!user.id) return null if (trimStr(user.wechatId || user.wechat_id)) return null if (isInCooldown('bind_wechat')) return null setCooldown('bind_wechat') const info = getRuleInfo(rules, '绑定微信') return { ruleId: 'bind_wechat', serverRuleId: info?.id, title: info?.title || '绑定微信号', message: info?.description || '绑定后可用于分销结算、提现核对与重要通知。', confirmText: '去设置', cancelText: '关闭', action: 'navigate', target: '/pages/settings/settings' } } function checkRule_Withdraw(rules) { if (!isRuleEnabled(rules, '收益满50元')) return null const user = getUserInfo() if (!user.id) return null const app = getAppInstance() const earnings = app ? (app.globalData.totalEarnings || 0) : 0 if (earnings < 50) return null if (isInCooldown('withdraw_50')) return null setCooldown('withdraw_50') const info = getRuleInfo(rules, '收益满50元') return { ruleId: 'withdraw_50', serverRuleId: info?.id, title: info?.title || '可以提现了', message: info?.description || '累计分销收益已达到提现条件,可在推荐收益页发起提现到微信零钱。', confirmText: '去查看', cancelText: '关闭', action: 'navigate', target: '/pages/referral/referral' } } function checkRulesSync(scene, rules) { const user = getUserInfo() if (!user.id) return null switch (scene) { case 'after_login': return checkAvatarNicknameGuides(rules) case 'before_read': return checkRule_BindPhone(rules) || checkAvatarNicknameGuides(rules) case 'before_pay': return checkAvatarNicknameGuides(rules) || checkRule_BindPhone(rules) || checkRule_FillProfile(rules) case 'after_match': return null case 'after_pay': return checkRule_FillVipInfo(rules) || checkRule_FillProfile(rules) case 'page_show': return checkAvatarNicknameGuides(rules) || checkRule_ShareAfter5Chapters(rules) || checkRule_BindWechat(rules) || checkRule_Withdraw(rules) case 'before_join_party': return checkRule_JoinParty(rules) default: return null } } function executeRule(rule, pageInstance) { if (!rule) return wx.showModal({ title: rule.title, content: rule.message, confirmText: rule.confirmText || '去填写', cancelText: rule.cancelText !== undefined ? rule.cancelText : '关闭', success: (res) => { if (res.confirm) { if (rule.action === 'navigate' && rule.target) { wx.navigateTo({ url: rule.target }) } else if (rule.action === 'bind_phone' && pageInstance) { if (typeof pageInstance.showPhoneBinding === 'function') { pageInstance.showPhoneBinding() } } if (rule.serverRuleId) { markRuleCompleted(rule.serverRuleId) } } _trackRuleAction(rule.ruleId, res.confirm ? 'confirm' : 'cancel') } }) } function _trackRuleAction(ruleId, action) { const userId = getUserInfo().id if (!userId) return const app = getAppInstance() if (!app) return app.request({ url: '/api/miniprogram/track', method: 'POST', data: { userId, action: 'rule_trigger', target: ruleId, extraData: { result: action } }, silent: true }).catch(() => {}) } async function checkAndExecute(scene, pageInstance) { const rules = await loadRules() let rule = null if (scene === 'after_match') { const u = await fetchProfileMergeUser() rule = checkRule_FillProfile(rules, u) || checkRule_JoinParty(rules, u) } else { rule = checkRulesSync(scene, rules) } if (rule) { setTimeout(() => executeRule(rule, pageInstance), 800) } } module.exports = { checkRules: checkRulesSync, executeRule, checkAndExecute, loadRules, markRuleCompleted }