Files
soul-yongping/miniprogram/utils/ruleEngine.js
卡若 fa3da12b16 feat: 小程序阅读记录与资料链路、管理端用户规则、API/VIP/推荐与运营脚本
- miniprogram: reading-records、imageUrl/mpNavigate、多页资料与 VIP 展示调整
- soul-admin: Users/Settings/UserDetailModal、dist 构建产物更新
- soul-api: user/vip/referral/ckb/db、MBTI 头像管理、user_rule_completion、迁移 SQL
- .cursor: karuo-party 与飞书文档;.gitignore 忽略 .tmp_skill_bundle

Made-with: Cursor
2026-03-23 18:38:23 +08:00

407 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 卡若创业派对 - 用户旅程规则引擎
* 从后端 /api/miniprogram/user-rules 读取启用的规则,按场景触发提示(文案偏利他、少用命令式)
* 稳定版兼容readCount 用 getReadCount()hasPurchasedFull 用 hasFullBook完善头像跳 avatar-nickname
*
* trigger → scene 映射:
* 注册 → after_login
* 点击收费章节 → 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',
'点击收费章节': 'before_read',
'完成匹配': 'after_match',
'完成付款': 'after_pay',
'发起支付': 'before_pay',
'累计浏览5章节': 'page_show',
'加入派对房': 'before_join_party',
'绑定微信': 'after_bindwechat',
'收益满50元': 'earnings_check',
'手动触发': 'manual',
'浏览导师页': 'browse_mentor',
}
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 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(() => {})
}
// 稳定版:跳转 avatar-nickname专注头像+昵称,首次登录由 app.login 强制 redirect
// 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 avatar = user.avatar || user.avatarUrl || ''
const nickname = user.nickname || ''
if (avatar && !avatar.includes('default') && nickname && nickname !== '微信用户' && !nickname.startsWith('微信用户')) return null
if (isInCooldown('fill_avatar')) return null
setCooldown('fill_avatar')
const info = getRuleInfo(rules, '注册')
return {
ruleId: 'fill_avatar',
serverRuleId: info?.id,
title: info?.title || '设置头像与昵称',
message: info?.description || '头像与昵称会展示在名片与匹配卡片上,方便伙伴认出你。',
confirmText: '去设置',
cancelText: '关闭',
action: 'navigate',
target: '/pages/avatar-nickname/avatar-nickname'
}
}
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 checkRule_FillAvatar(rules)
case 'before_read':
return checkRule_BindPhone(rules) || checkRule_FillAvatar(rules)
case 'before_pay':
return checkRule_FillAvatar(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 checkRule_FillAvatar(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 }