diff --git a/miniprogram/app.js b/miniprogram/app.js index 53a7282e..4bec7d88 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -4,6 +4,7 @@ */ const { parseScene } = require('./utils/scene.js') +const { checkAndExecute } = require('./utils/ruleEngine.js') App({ globalData: { @@ -209,31 +210,6 @@ App({ 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(用于跳转) getSectionMid(sectionId) { const list = this.globalData.bookData || [] @@ -521,8 +497,8 @@ App({ this.bindReferralCode(pendingRef) } - // 登录后引导完善资料 - this._ensureProfileCompletedAfterLogin(user) + // 登录后引导完善资料(规则引擎接管,完善头像吸收到规则引擎) + checkAndExecute('after_login', null) } return res.data @@ -583,8 +559,8 @@ App({ this.bindReferralCode(pendingRef) } - // 登录后引导完善资料 - this._ensureProfileCompletedAfterLogin(user) + // 登录后引导完善资料(规则引擎接管) + checkAndExecute('after_login', null) } return res.data.openId } @@ -637,8 +613,8 @@ App({ this.bindReferralCode(pendingRef) } - // 登录后引导完善资料 - this._ensureProfileCompletedAfterLogin(user) + // 登录后引导完善资料(规则引擎接管) + checkAndExecute('after_login', null) return res.data } diff --git a/miniprogram/pages/match/match.js b/miniprogram/pages/match/match.js index fe936ef6..26a2f6aa 100644 --- a/miniprogram/pages/match/match.js +++ b/miniprogram/pages/match/match.js @@ -5,6 +5,7 @@ */ const app = getApp() +const { checkAndExecute } = require('../../utils/ruleEngine.js') // 默认匹配类型配置 // 找伙伴:真正的匹配功能,匹配数据库中的真实用户 @@ -511,6 +512,8 @@ Page({ } } }) + // 匹配后规则:引导填写 MBTI/行业信息 + checkAndExecute('after_match', this) } catch (e) { console.log('上报匹配失败:', e) } diff --git a/miniprogram/utils/ruleEngine.js b/miniprogram/utils/ruleEngine.js new file mode 100644 index 00000000..925e802e --- /dev/null +++ b/miniprogram/utils/ruleEngine.js @@ -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 } diff --git a/soul-api/internal/database/database.go b/soul-api/internal/database/database.go index d478c01b..2ca3608f 100644 --- a/soul-api/internal/database/database.go +++ b/soul-api/internal/database/database.go @@ -108,6 +108,9 @@ func Init(dsn string) error { if err := db.AutoMigrate(&model.GiftPayRequest{}); err != nil { 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") return nil } diff --git a/soul-api/internal/handler/admin_user_rules.go b/soul-api/internal/handler/admin_user_rules.go index a99604a4..4e2cce68 100644 --- a/soul-api/internal/handler/admin_user_rules.go +++ b/soul-api/internal/handler/admin_user_rules.go @@ -22,6 +22,17 @@ func DBUserRulesList(c *gin.Context) { 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 func DBUserRulesAction(c *gin.Context) { db := database.DB() diff --git a/soul-api/internal/router/router.go b/soul-api/internal/router/router.go index d3d99dd2..f8e7d173 100644 --- a/soul-api/internal/router/router.go +++ b/soul-api/internal/router/router.go @@ -180,6 +180,10 @@ func Setup(cfg *config.Config) *gin.Engine { db.DELETE("/link-tags", handler.DBLinkTagDelete) db.GET("/ckb-leads", handler.DBCKBLeadList) 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.POST("/track", handler.MiniprogramTrackPost) + // 规则引擎(用户旅程引导) + miniprogram.GET("/user-rules", handler.MiniprogramUserRulesGet) // 余额 miniprogram.GET("/balance", handler.BalanceGet) miniprogram.GET("/balance/transactions", handler.BalanceTransactionsGet) diff --git a/soul-api/scripts/add-user-rules-default.sql b/soul-api/scripts/add-user-rules-default.sql new file mode 100644 index 00000000..83b017cd --- /dev/null +++ b/soul-api/scripts/add-user-rules-default.sql @@ -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); diff --git a/开发文档/规则引擎迁移-影响分析.md b/开发文档/规则引擎迁移-影响分析.md new file mode 100644 index 00000000..e04b1fcb --- /dev/null +++ b/开发文档/规则引擎迁移-影响分析.md @@ -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,无需额外配置 diff --git a/开发文档/迁移完成度与待办清单.md b/开发文档/迁移完成度与待办清单.md index b95a7b23..cf83c936 100644 --- a/开发文档/迁移完成度与待办清单.md +++ b/开发文档/迁移完成度与待办清单.md @@ -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 等页的关键操作 | -| **规则引擎 ruleEngine** | 迁移方案「不迁移」,稳定版无 | 若需迁移:ruleEngine.js + GET /api/miniprogram/user-rules(soul-api 需新增 miniprogram 路由) | - -**埋点遗漏页**:match(加好友、加入提交)、index(链接卡若、VIP 等)、my、vip、search、referral、gift-pay 等。 +| **埋点 trackClick** | 已接入:chapters、read、wallet | 遗漏:index、my、match、vip、search、referral 等页 | --- @@ -107,4 +114,4 @@ **核心迁移已全部完成**:余额体系、代付美团式、埋点、首页/目录/阅读/VIP 相关功能均已落地。 -**剩余**:导师预约支付(暂不处理);规则与埋点待补充(见上表)。富文本、打包购买引导、存客宝对接均已确认稳定版已有。 +**剩余**:导师预约支付(暂不处理);埋点待补充(见上表)。规则引擎已迁移,富文本、打包购买引导、存客宝对接均已确认稳定版已有。