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
This commit is contained in:
14
miniprogram/utils/imageUrl.js
Normal file
14
miniprogram/utils/imageUrl.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 小程序 <image src> 合法判断:避免 undefined 字符串、相对脏值触发「illegal src」
|
||||
*/
|
||||
function isSafeImageSrc(u) {
|
||||
if (u == null) return false
|
||||
const s = String(u).trim()
|
||||
if (!s || s === 'undefined' || s === 'null') return false
|
||||
if (/^https?:\/\//i.test(s)) return true
|
||||
if (s.startsWith('wxfile://') || s.startsWith('cloud://')) return true
|
||||
if (s.startsWith('/')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
module.exports = { isSafeImageSrc }
|
||||
26
miniprogram/utils/mpNavigate.js
Normal file
26
miniprogram/utils/mpNavigate.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 按 mp_config.mpUi 配置的路径跳转:Tab 页用 switchTab,其余 navigateTo
|
||||
*/
|
||||
const TAB_PATHS = [
|
||||
'/pages/index/index',
|
||||
'/pages/chapters/chapters',
|
||||
'/pages/match/match',
|
||||
'/pages/my/my'
|
||||
]
|
||||
|
||||
function navigateMpPath(path) {
|
||||
if (!path || typeof path !== 'string') return false
|
||||
const full = path.trim()
|
||||
if (!full.startsWith('/')) return false
|
||||
const q = full.indexOf('?')
|
||||
const route = q >= 0 ? full.slice(0, q) : full
|
||||
const suffix = q >= 0 ? full.slice(q) : ''
|
||||
if (TAB_PATHS.includes(route)) {
|
||||
wx.switchTab({ url: route })
|
||||
return true
|
||||
}
|
||||
wx.navigateTo({ url: route + suffix })
|
||||
return true
|
||||
}
|
||||
|
||||
module.exports = { navigateMpPath, TAB_PATHS }
|
||||
@@ -32,13 +32,13 @@ class ReadingTracker {
|
||||
|
||||
console.log('[ReadingTracker] 初始化追踪:', sectionId)
|
||||
|
||||
// 恢复上次阅读位置
|
||||
this.saveProgressLocal()
|
||||
app.touchRecentSection(sectionId)
|
||||
|
||||
this.restoreLastPosition(sectionId)
|
||||
|
||||
// 开始定期上报(每30秒)
|
||||
this.startProgressReport()
|
||||
|
||||
// 立即上报一次「打开/点击」,确保内容管理后台的「点击」数据有记录(与 reading_progress 表直接捆绑)
|
||||
setTimeout(() => this.reportProgressToServer(false), 0)
|
||||
}
|
||||
|
||||
@@ -177,16 +177,20 @@ class ReadingTracker {
|
||||
this.activeTracker.lastScrollTime = now
|
||||
|
||||
try {
|
||||
const data = {
|
||||
userId,
|
||||
sectionId: this.activeTracker.sectionId,
|
||||
progress: this.activeTracker.maxProgress,
|
||||
duration: this.activeTracker.totalDuration,
|
||||
status: this.activeTracker.isCompleted ? 'completed' : 'reading'
|
||||
}
|
||||
if (this.activeTracker.isCompleted && this.activeTracker.completedAt != null) {
|
||||
const t = this.activeTracker.completedAt
|
||||
data.completedAt = typeof t === 'number' ? new Date(t).toISOString() : String(t)
|
||||
}
|
||||
await app.request('/api/miniprogram/user/reading-progress', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
userId,
|
||||
sectionId: this.activeTracker.sectionId,
|
||||
progress: this.activeTracker.maxProgress,
|
||||
duration: this.activeTracker.totalDuration,
|
||||
status: this.activeTracker.isCompleted ? 'completed' : 'reading',
|
||||
completedAt: this.activeTracker.completedAt
|
||||
}
|
||||
data
|
||||
})
|
||||
|
||||
if (isCompletion) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 卡若创业派对 - 用户旅程规则引擎
|
||||
* 从后端 /api/miniprogram/user-rules 读取启用的规则,按场景触发引导
|
||||
* 从后端 /api/miniprogram/user-rules 读取启用的规则,按场景触发提示(文案偏利他、少用命令式)
|
||||
* 稳定版兼容:readCount 用 getReadCount(),hasPurchasedFull 用 hasFullBook,完善头像跳 avatar-nickname
|
||||
*
|
||||
* trigger → scene 映射:
|
||||
@@ -37,6 +37,7 @@ const TRIGGER_SCENE_MAP = {
|
||||
'点击收费章节': 'before_read',
|
||||
'完成匹配': 'after_match',
|
||||
'完成付款': 'after_pay',
|
||||
'发起支付': 'before_pay',
|
||||
'累计浏览5章节': 'page_show',
|
||||
'加入派对房': 'before_join_party',
|
||||
'绑定微信': 'after_bindwechat',
|
||||
@@ -74,12 +75,44 @@ function getUserInfo() {
|
||||
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 res = await app.request({ url: '/api/miniprogram/user-rules', method: 'GET', silent: true })
|
||||
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()
|
||||
@@ -92,15 +125,30 @@ async function loadRules() {
|
||||
}
|
||||
|
||||
function isRuleEnabled(rules, triggerName) {
|
||||
return rules.some(r => r.trigger === triggerName)
|
||||
return rules.some(r => r.trigger === triggerName && !r.completed)
|
||||
}
|
||||
|
||||
function getRuleInfo(rules, triggerName) {
|
||||
return rules.find(r => r.trigger === 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,避免与主流程冲突
|
||||
// VIP 用户不触发:统一由 checkVipContactRequiredAndGuide 跳转 profile-edit,避免与主流程冲突
|
||||
function checkRule_FillAvatar(rules) {
|
||||
if (!isRuleEnabled(rules, '注册')) return null
|
||||
const app = getAppInstance()
|
||||
@@ -115,8 +163,11 @@ function checkRule_FillAvatar(rules) {
|
||||
const info = getRuleInfo(rules, '注册')
|
||||
return {
|
||||
ruleId: 'fill_avatar',
|
||||
title: info?.title || '完善个人信息',
|
||||
message: info?.description || '设置头像和昵称,让其他创业者更容易认识你',
|
||||
serverRuleId: info?.id,
|
||||
title: info?.title || '设置头像与昵称',
|
||||
message: info?.description || '头像与昵称会展示在名片与匹配卡片上,方便伙伴认出你。',
|
||||
confirmText: '去设置',
|
||||
cancelText: '关闭',
|
||||
action: 'navigate',
|
||||
target: '/pages/avatar-nickname/avatar-nickname'
|
||||
}
|
||||
@@ -132,25 +183,34 @@ function checkRule_BindPhone(rules) {
|
||||
const info = getRuleInfo(rules, '点击收费章节')
|
||||
return {
|
||||
ruleId: 'bind_phone',
|
||||
serverRuleId: info?.id,
|
||||
title: info?.title || '绑定手机号',
|
||||
message: info?.description || '绑定手机号解锁更多功能,保障账户安全',
|
||||
message: info?.description || '绑定后可用于登录验证、收益与重要通知,账户更安全。',
|
||||
confirmText: '去绑定',
|
||||
cancelText: '关闭',
|
||||
action: 'bind_phone',
|
||||
target: null
|
||||
}
|
||||
}
|
||||
|
||||
function checkRule_FillProfile(rules) {
|
||||
function checkRule_FillProfile(rules, user) {
|
||||
if (!isRuleEnabled(rules, '完成匹配')) return null
|
||||
const user = getUserInfo()
|
||||
user = user || getUserInfo()
|
||||
if (!user.id) return null
|
||||
if (user.mbti && user.industry) 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',
|
||||
title: info?.title || '完善创业档案',
|
||||
message: info?.description || '填写 MBTI 和行业信息,帮你精准匹配创业伙伴',
|
||||
serverRuleId: info?.id,
|
||||
title: info?.title || '补充档案信息',
|
||||
message: info?.description || '补全 MBTI、行业和职位后,匹配页能更准确地向对方展示你,减少无效沟通。',
|
||||
confirmText: '去填写',
|
||||
cancelText: '关闭',
|
||||
action: 'navigate',
|
||||
target: '/pages/profile-edit/profile-edit'
|
||||
}
|
||||
@@ -169,45 +229,56 @@ function checkRule_ShareAfter5Chapters(rules) {
|
||||
const info = getRuleInfo(rules, '累计浏览5章节')
|
||||
return {
|
||||
ruleId: 'share_after_5',
|
||||
serverRuleId: info?.id,
|
||||
title: info?.title || '邀请好友一起看',
|
||||
message: info?.description || '你已阅读 ' + readCount + ' 个章节,分享给好友可获得分销收益',
|
||||
message: info?.description || '你已阅读 ' + readCount + ' 个章节,好友通过你的分享购买时,你可获得对应分销收益。',
|
||||
confirmText: '查看分享',
|
||||
cancelText: '关闭',
|
||||
action: 'navigate',
|
||||
target: '/pages/referral/referral'
|
||||
}
|
||||
}
|
||||
|
||||
// 稳定版兼容:hasPurchasedFull 用 hasFullBook
|
||||
function checkRule_FillVipInfo(rules) {
|
||||
function checkRule_FillVipInfo(rules, user) {
|
||||
if (!isRuleEnabled(rules, '完成付款')) return null
|
||||
const user = getUserInfo()
|
||||
user = user || getUserInfo()
|
||||
if (!user.id) return null
|
||||
const app = getAppInstance()
|
||||
if (!app || !(app.globalData.hasFullBook || app.globalData.hasPurchasedFull)) return null
|
||||
if (user.wechatId && user.address) 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',
|
||||
title: info?.title || '填写完整信息',
|
||||
message: info?.description || '购买全书后,需填写完整信息以进入 VIP 群',
|
||||
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) {
|
||||
function checkRule_JoinParty(rules, user) {
|
||||
if (!isRuleEnabled(rules, '加入派对房')) return null
|
||||
const user = getUserInfo()
|
||||
user = user || getUserInfo()
|
||||
if (!user.id) return null
|
||||
if (user.projectIntro) 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',
|
||||
title: info?.title || '填写项目介绍',
|
||||
message: info?.description || '进入派对房前,引导填写项目介绍和核心需求',
|
||||
serverRuleId: info?.id,
|
||||
title: info?.title || '补充项目介绍',
|
||||
message: info?.description || '用简短文字说明项目与需求,派对房里的伙伴能更快判断是否与你有合作空间。',
|
||||
confirmText: '去填写',
|
||||
cancelText: '关闭',
|
||||
action: 'navigate',
|
||||
target: '/pages/profile-edit/profile-edit'
|
||||
}
|
||||
@@ -217,14 +288,17 @@ function checkRule_BindWechat(rules) {
|
||||
if (!isRuleEnabled(rules, '绑定微信')) return null
|
||||
const user = getUserInfo()
|
||||
if (!user.id) return null
|
||||
if (user.wechatId) 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 || '绑定微信后,引导开启分销功能',
|
||||
message: info?.description || '绑定后可用于分销结算、提现核对与重要通知。',
|
||||
confirmText: '去设置',
|
||||
cancelText: '关闭',
|
||||
action: 'navigate',
|
||||
target: '/pages/settings/settings'
|
||||
}
|
||||
@@ -242,8 +316,11 @@ function checkRule_Withdraw(rules) {
|
||||
const info = getRuleInfo(rules, '收益满50元')
|
||||
return {
|
||||
ruleId: 'withdraw_50',
|
||||
serverRuleId: info?.id,
|
||||
title: info?.title || '可以提现了',
|
||||
message: info?.description || '累计分销收益超过 50 元,快去申请提现吧',
|
||||
message: info?.description || '累计分销收益已达到提现条件,可在推荐收益页发起提现到微信零钱。',
|
||||
confirmText: '去查看',
|
||||
cancelText: '关闭',
|
||||
action: 'navigate',
|
||||
target: '/pages/referral/referral'
|
||||
}
|
||||
@@ -258,8 +335,10 @@ function checkRulesSync(scene, rules) {
|
||||
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 checkRule_FillProfile(rules) || checkRule_JoinParty(rules)
|
||||
return null
|
||||
case 'after_pay':
|
||||
return checkRule_FillVipInfo(rules) || checkRule_FillProfile(rules)
|
||||
case 'page_show':
|
||||
@@ -277,8 +356,8 @@ function executeRule(rule, pageInstance) {
|
||||
wx.showModal({
|
||||
title: rule.title,
|
||||
content: rule.message,
|
||||
confirmText: '去完善',
|
||||
cancelText: '稍后再说',
|
||||
confirmText: rule.confirmText || '去填写',
|
||||
cancelText: rule.cancelText !== undefined ? rule.cancelText : '关闭',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
if (rule.action === 'navigate' && rule.target) {
|
||||
@@ -288,6 +367,9 @@ function executeRule(rule, pageInstance) {
|
||||
pageInstance.showPhoneBinding()
|
||||
}
|
||||
}
|
||||
if (rule.serverRuleId) {
|
||||
markRuleCompleted(rule.serverRuleId)
|
||||
}
|
||||
}
|
||||
_trackRuleAction(rule.ruleId, res.confirm ? 'confirm' : 'cancel')
|
||||
}
|
||||
@@ -309,10 +391,16 @@ function _trackRuleAction(ruleId, action) {
|
||||
|
||||
async function checkAndExecute(scene, pageInstance) {
|
||||
const rules = await loadRules()
|
||||
const rule = checkRulesSync(scene, rules)
|
||||
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 }
|
||||
module.exports = { checkRules: checkRulesSync, executeRule, checkAndExecute, loadRules, markRuleCompleted }
|
||||
|
||||
@@ -32,6 +32,19 @@ const formatMoney = (amount, decimals = 2) => {
|
||||
return Number(amount).toFixed(decimals)
|
||||
}
|
||||
|
||||
/** 「我的」等页统计数字展示:非法值→0;≥1 万可缩写为「x万」 */
|
||||
const formatStatNum = (n) => {
|
||||
const x = Number(n)
|
||||
if (Number.isNaN(x) || !Number.isFinite(x)) return '0'
|
||||
const v = Math.floor(x)
|
||||
if (v >= 10000) {
|
||||
const w = v / 10000
|
||||
const s = w >= 10 ? String(Math.floor(w)) : String(Math.round(w * 10) / 10).replace(/\.0$/, '')
|
||||
return s + '万'
|
||||
}
|
||||
return String(v)
|
||||
}
|
||||
|
||||
// 防抖函数
|
||||
const debounce = (fn, delay = 300) => {
|
||||
let timer = null
|
||||
@@ -189,6 +202,7 @@ module.exports = {
|
||||
formatTime,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
formatStatNum,
|
||||
formatNumber,
|
||||
debounce,
|
||||
throttle,
|
||||
|
||||
Reference in New Issue
Block a user