feat: 对齐规则引擎

This commit is contained in:
Alex-larget
2026-03-17 12:21:33 +08:00
parent 601044ec60
commit fcc05b6420
9 changed files with 425 additions and 37 deletions

View File

@@ -4,6 +4,7 @@
*/ */
const { parseScene } = require('./utils/scene.js') const { parseScene } = require('./utils/scene.js')
const { checkAndExecute } = require('./utils/ruleEngine.js')
App({ App({
globalData: { globalData: {
@@ -209,31 +210,6 @@ App({
return code.replace(/[\s\-_]/g, '').toUpperCase().trim() return code.replace(/[\s\-_]/g, '').toUpperCase().trim()
}, },
// 判断用户资料是否完善(昵称 + 头像)
_isProfileIncomplete(user) {
if (!user) return true
const nickname = (user.nickname || '').trim()
const avatar = (user.avatar || '').trim()
const isDefaultNickname = !nickname || nickname === '微信用户'
const noAvatar = !avatar
return isDefaultNickname || noAvatar
},
// 登录后若资料未完善,引导跳转到头像昵称引导页
_ensureProfileCompletedAfterLogin(user) {
try {
if (!user || !this._isProfileIncomplete(user)) return
const pages = getCurrentPages()
const current = pages[pages.length - 1]
// 避免在头像昵称页或资料编辑页内重复跳转
if (current && (current.route === 'pages/avatar-nickname/avatar-nickname' || current.route === 'pages/profile-edit/profile-edit')) return
wx.showToast({ title: '请先完善头像和昵称', icon: 'none', duration: 2000 })
wx.navigateTo({ url: '/pages/avatar-nickname/avatar-nickname' })
} catch (e) {
console.warn('[App] 跳转资料编辑页失败:', e)
}
},
// 根据业务 id 从 bookData 查 mid用于跳转 // 根据业务 id 从 bookData 查 mid用于跳转
getSectionMid(sectionId) { getSectionMid(sectionId) {
const list = this.globalData.bookData || [] const list = this.globalData.bookData || []
@@ -521,8 +497,8 @@ App({
this.bindReferralCode(pendingRef) this.bindReferralCode(pendingRef)
} }
// 登录后引导完善资料 // 登录后引导完善资料(规则引擎接管,完善头像吸收到规则引擎)
this._ensureProfileCompletedAfterLogin(user) checkAndExecute('after_login', null)
} }
return res.data return res.data
@@ -583,8 +559,8 @@ App({
this.bindReferralCode(pendingRef) this.bindReferralCode(pendingRef)
} }
// 登录后引导完善资料 // 登录后引导完善资料(规则引擎接管)
this._ensureProfileCompletedAfterLogin(user) checkAndExecute('after_login', null)
} }
return res.data.openId return res.data.openId
} }
@@ -637,8 +613,8 @@ App({
this.bindReferralCode(pendingRef) this.bindReferralCode(pendingRef)
} }
// 登录后引导完善资料 // 登录后引导完善资料(规则引擎接管)
this._ensureProfileCompletedAfterLogin(user) checkAndExecute('after_login', null)
return res.data return res.data
} }

View File

@@ -5,6 +5,7 @@
*/ */
const app = getApp() const app = getApp()
const { checkAndExecute } = require('../../utils/ruleEngine.js')
// 默认匹配类型配置 // 默认匹配类型配置
// 找伙伴:真正的匹配功能,匹配数据库中的真实用户 // 找伙伴:真正的匹配功能,匹配数据库中的真实用户
@@ -511,6 +512,8 @@ Page({
} }
} }
}) })
// 匹配后规则:引导填写 MBTI/行业信息
checkAndExecute('after_match', this)
} catch (e) { } catch (e) {
console.log('上报匹配失败:', e) console.log('上报匹配失败:', e)
} }

View File

@@ -0,0 +1,297 @@
/**
* Soul创业派对 - 用户旅程规则引擎
* 从后端 /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
*/
const app = getApp()
const RULE_COOLDOWN_KEY = 'rule_engine_cooldown'
const COOLDOWN_MS = 60 * 1000
let _cachedRules = null
let _cacheTs = 0
const CACHE_TTL = 5 * 60 * 1000
const TRIGGER_SCENE_MAP = {
'注册': 'after_login',
'点击收费章节': 'before_read',
'完成匹配': 'after_match',
'完成付款': 'after_pay',
'累计浏览5章节': 'page_show',
'加入派对房': 'before_join_party',
'绑定微信': 'after_bindwechat',
'收益满50元': 'earnings_check',
'手动触发': 'manual',
'浏览导师页': 'browse_mentor',
}
function isInCooldown(ruleId) {
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) {
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() {
return app.globalData.userInfo || {}
}
async function loadRules() {
if (_cachedRules && Date.now() - _cacheTs < CACHE_TTL) return _cachedRules
try {
const res = await app.request({ url: '/api/miniprogram/user-rules', 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)
}
function getRuleInfo(rules, triggerName) {
return rules.find(r => r.trigger === triggerName)
}
// 稳定版:跳转 avatar-nickname与 _ensureProfileCompletedAfterLogin 一致)
function checkRule_FillAvatar(rules) {
if (!isRuleEnabled(rules, '注册')) 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',
title: info?.title || '完善个人信息',
message: info?.description || '设置头像和昵称,让其他创业者更容易认识你',
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',
title: info?.title || '绑定手机号',
message: info?.description || '绑定手机号解锁更多功能,保障账户安全',
action: 'bind_phone',
target: null
}
}
function checkRule_FillProfile(rules) {
if (!isRuleEnabled(rules, '完成匹配')) return null
const user = getUserInfo()
if (!user.id) return null
if (user.mbti && user.industry) return null
if (isInCooldown('fill_profile')) return null
setCooldown('fill_profile')
const info = getRuleInfo(rules, '完成匹配')
return {
ruleId: 'fill_profile',
title: info?.title || '完善创业档案',
message: info?.description || '填写 MBTI 和行业信息,帮你精准匹配创业伙伴',
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 readCount = (typeof app.getReadCount === 'function' ? app.getReadCount() : (app.globalData.readCount || 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',
title: info?.title || '邀请好友一起看',
message: info?.description || '你已阅读 ' + readCount + ' 个章节,分享给好友可获得分销收益',
action: 'navigate',
target: '/pages/referral/referral'
}
}
// 稳定版兼容hasPurchasedFull 用 hasFullBook
function checkRule_FillVipInfo(rules) {
if (!isRuleEnabled(rules, '完成付款')) return null
const user = getUserInfo()
if (!user.id) return null
if (!(app.globalData.hasFullBook || app.globalData.hasPurchasedFull)) return null
if (user.wechatId && user.address) return null
if (isInCooldown('fill_vip_info')) return null
setCooldown('fill_vip_info')
const info = getRuleInfo(rules, '完成付款')
return {
ruleId: 'fill_vip_info',
title: info?.title || '填写完整信息',
message: info?.description || '购买全书后,需填写完整信息以进入 VIP 群',
action: 'navigate',
target: '/pages/profile-edit/profile-edit'
}
}
function checkRule_JoinParty(rules) {
if (!isRuleEnabled(rules, '加入派对房')) return null
const user = getUserInfo()
if (!user.id) return null
if (user.projectIntro) return null
if (isInCooldown('join_party')) return null
setCooldown('join_party')
const info = getRuleInfo(rules, '加入派对房')
return {
ruleId: 'join_party',
title: info?.title || '填写项目介绍',
message: info?.description || '进入派对房前,引导填写项目介绍和核心需求',
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 (user.wechatId) return null
if (isInCooldown('bind_wechat')) return null
setCooldown('bind_wechat')
const info = getRuleInfo(rules, '绑定微信')
return {
ruleId: 'bind_wechat',
title: info?.title || '绑定微信号',
message: info?.description || '绑定微信后,引导开启分销功能',
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 earnings = app.globalData.totalEarnings || 0
if (earnings < 50) return null
if (isInCooldown('withdraw_50')) return null
setCooldown('withdraw_50')
const info = getRuleInfo(rules, '收益满50元')
return {
ruleId: 'withdraw_50',
title: info?.title || '可以提现了',
message: info?.description || '累计分销收益超过 50 元,快去申请提现吧',
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 'after_match':
return checkRule_FillProfile(rules) || checkRule_JoinParty(rules)
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: '去完善',
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()
}
}
}
_trackRuleAction(rule.ruleId, res.confirm ? 'confirm' : 'cancel')
}
})
}
function _trackRuleAction(ruleId, action) {
const userId = getUserInfo().id
if (!userId) 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()
const rule = checkRulesSync(scene, rules)
if (rule) {
setTimeout(() => executeRule(rule, pageInstance), 800)
}
}
module.exports = { checkRules: checkRulesSync, executeRule, checkAndExecute, loadRules }

View File

@@ -108,6 +108,9 @@ func Init(dsn string) error {
if err := db.AutoMigrate(&model.GiftPayRequest{}); err != nil { if err := db.AutoMigrate(&model.GiftPayRequest{}); err != nil {
log.Printf("database: gift_pay_requests migrate warning: %v", err) log.Printf("database: gift_pay_requests migrate warning: %v", err)
} }
if err := db.AutoMigrate(&model.UserRule{}); err != nil {
log.Printf("database: user_rules migrate warning: %v", err)
}
log.Println("database: connected") log.Println("database: connected")
return nil return nil
} }

View File

@@ -22,6 +22,17 @@ func DBUserRulesList(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "rules": rules}) c.JSON(http.StatusOK, gin.H{"success": true, "rules": rules})
} }
// MiniprogramUserRulesGet GET /api/miniprogram/user-rules 小程序规则引擎:返回启用的规则,无需鉴权
func MiniprogramUserRulesGet(c *gin.Context) {
db := database.DB()
var rules []model.UserRule
if err := db.Where("enabled = ?", true).Order("sort ASC, id ASC").Find(&rules).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "rules": rules})
}
// DBUserRulesAction POST/PUT/DELETE /api/db/user-rules // DBUserRulesAction POST/PUT/DELETE /api/db/user-rules
func DBUserRulesAction(c *gin.Context) { func DBUserRulesAction(c *gin.Context) {
db := database.DB() db := database.DB()

View File

@@ -180,6 +180,10 @@ func Setup(cfg *config.Config) *gin.Engine {
db.DELETE("/link-tags", handler.DBLinkTagDelete) db.DELETE("/link-tags", handler.DBLinkTagDelete)
db.GET("/ckb-leads", handler.DBCKBLeadList) db.GET("/ckb-leads", handler.DBCKBLeadList)
db.GET("/ckb-plan-stats", handler.CKBPlanStats) db.GET("/ckb-plan-stats", handler.CKBPlanStats)
db.GET("/user-rules", handler.DBUserRulesList)
db.POST("/user-rules", handler.DBUserRulesAction)
db.PUT("/user-rules", handler.DBUserRulesAction)
db.DELETE("/user-rules", handler.DBUserRulesAction)
} }
// ----- 分销 ----- // ----- 分销 -----
@@ -322,6 +326,8 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.GET("/about/author", handler.MiniprogramAboutAuthor) miniprogram.GET("/about/author", handler.MiniprogramAboutAuthor)
// 埋点 // 埋点
miniprogram.POST("/track", handler.MiniprogramTrackPost) miniprogram.POST("/track", handler.MiniprogramTrackPost)
// 规则引擎(用户旅程引导)
miniprogram.GET("/user-rules", handler.MiniprogramUserRulesGet)
// 余额 // 余额
miniprogram.GET("/balance", handler.BalanceGet) miniprogram.GET("/balance", handler.BalanceGet)
miniprogram.GET("/balance/transactions", handler.BalanceTransactionsGet) miniprogram.GET("/balance/transactions", handler.BalanceTransactionsGet)

View File

@@ -0,0 +1,8 @@
-- 规则引擎默认数据:插入「注册」规则,供登录后完善头像引导
-- 执行mysql -u user -p db < soul-api/scripts/add-user-rules-default.sql
-- 幂等:若已存在 trigger='注册' 则跳过
INSERT INTO user_rules (title, description, `trigger`, sort, enabled, created_at, updated_at)
SELECT '完善个人信息', '设置头像和昵称,让其他创业者更容易认识你', '注册', 1, 1, NOW(), NOW()
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM user_rules WHERE `trigger` = '注册' LIMIT 1);

View File

@@ -0,0 +1,77 @@
# 规则引擎迁移 - 影响分析
> 乘风严格分析确保不破坏现有功能。2026-03-17
---
## 一、现有逻辑梳理
### 1. 完善头像逻辑(将被吸收)
| 调用位置 | 文件 | 行号 | 触发时机 |
|----------|------|------|----------|
| login 成功 | app.js | 525 | 微信 code 登录成功后 |
| getOpenId 登录 | app.js | 587 | getOpenId 时接口返回 user |
| loginWithPhone | app.js | 641 | 手机号登录成功后 |
**逻辑**`_ensureProfileCompletedAfterLogin(user)` → 若头像/昵称未完善 → Toast + `navigateTo` avatar-nickname
### 2. 规则引擎将接管
- **场景**after_login对应 trigger「注册」
- **规则**checkRule_FillAvatar
- **行为**Modal去完善/稍后再说)→ 确认则 navigateTo
- **目标页**:改为 avatar-nickname与稳定版一致
### 3. 破坏性风险点
| 风险 | 条件 | 后果 | 缓解 |
|------|------|------|------|
| 规则 API 404 | 后端未部署 user-rules | loadRules 返回 [],无引导 | 先部署后端 |
| user_rules 表空 | 无「注册」规则 | isRuleEnabled 为 false无引导 | SQL 脚本插入默认规则 |
| globalData 不兼容 | readCount/hasPurchasedFull | 部分规则不触发 | ruleEngine 内做兼容 |
| 目标页错误 | target=profile-edit | 跳错页 | 改为 avatar-nickname |
---
## 二、兼容性修改清单
### ruleEngine.js
| 修改项 | 原值 | 新值 | 原因 |
|--------|------|------|------|
| checkRule_FillAvatar target | profile-edit | avatar-nickname | 稳定版用 avatar-nickname |
| readCount | globalData.readCount | getReadCount() | 稳定版无 readCount |
| hasPurchasedFull | globalData.hasPurchasedFull | globalData.hasFullBook | 稳定版用 hasFullBook |
### 空规则兜底
`rules` 为空时after_login 不触发 → 用户无引导。**方案**SQL 脚本插入默认「注册」规则,部署时执行。
---
## 三、执行顺序(防破坏)
1. **soul-api**:新增 GET /api/miniprogram/user-rules
2. **soul-api**:确保 user_rules 表存在GORM AutoMigrate
3. **SQL**:插入默认「注册」规则(幂等)
4. **miniprogram**:复制 ruleEngine.js 并做兼容
5. **miniprogram**app.js 替换 _ensureProfileCompletedAfterLogin 为 checkAndExecute
6. **miniprogram**match.js 接入 after_match
---
## 四、回滚方案
若规则引擎导致问题:
1. app.js恢复 _ensureProfileCompletedAfterLogin 调用,注释 checkAndExecute
2. match.js移除 checkAndExecute 调用
3. 规则引擎仅新增不删,可随时停用
---
## 五、部署步骤2026-03-17 已执行)
1. **soul-api**:重启后 AutoMigrate 会创建 user_rules 表
2. **数据库**:执行 `soul-api/scripts/add-user-rules-default.sql` 插入默认「注册」规则
3. **miniprogram**:已接入 ruleEngine无需额外配置

View File

@@ -83,14 +83,21 @@
原搁置项富文本/打包引导/存客宝均已确认稳定版已有,无新增搁置。 原搁置项富文本/打包引导/存客宝均已确认稳定版已有,无新增搁置。
### 规则与埋点(待补充,2026-03-17 ### 规则引擎(2026-03-17 已迁移
| 项 | 状态 |
|----|:----:|
| ruleEngine.js | ✅ 已接入,兼容 readCount/hasFullBook |
| GET /api/miniprogram/user-rules | ✅ soul-api 已新增 |
| after_login完善头像 | ✅ 吸收 _ensureProfileCompletedAfterLogin跳 avatar-nickname |
| after_match匹配后引导 | ✅ match.js reportMatch 后触发 |
| user_rules 表 + 默认规则 | ✅ AutoMigrate + add-user-rules-default.sql |
### 埋点(待补充)
| 项 | 当前状态 | 待办 | | 项 | 当前状态 | 待办 |
|----|----------|------| |----|----------|------|
| **埋点 trackClick** | 已接入chapters、read、wallet | 遗漏index、my、match、vip、search、referral 等页的关键操作 | | **埋点 trackClick** | 已接入chapters、read、wallet | 遗漏index、my、match、vip、search、referral 等页 |
| **规则引擎 ruleEngine** | 迁移方案「不迁移」,稳定版无 | 若需迁移ruleEngine.js + GET /api/miniprogram/user-rulessoul-api 需新增 miniprogram 路由) |
**埋点遗漏页**match加好友、加入提交、index链接卡若、VIP 等、my、vip、search、referral、gift-pay 等。
--- ---
@@ -107,4 +114,4 @@
**核心迁移已全部完成**:余额体系、代付美团式、埋点、首页/目录/阅读/VIP 相关功能均已落地。 **核心迁移已全部完成**:余额体系、代付美团式、埋点、首页/目录/阅读/VIP 相关功能均已落地。
**剩余**:导师预约支付(暂不处理);规则与埋点待补充(见上表)。富文本、打包购买引导、存客宝对接均已确认稳定版已有。 **剩余**:导师预约支付(暂不处理);埋点待补充(见上表)。规则引擎已迁移,富文本、打包购买引导、存客宝对接均已确认稳定版已有。