Merge branch 'devlop' into yongxu-dev

# Conflicts:
#	miniprogram/app.js   resolved by devlop version
#	miniprogram/pages/chapters/chapters.js   resolved by devlop version
#	miniprogram/pages/match/match.js   resolved by devlop version
#	miniprogram/pages/member-detail/member-detail.js   resolved by devlop version
#	miniprogram/pages/my/my.js   resolved by devlop version
#	miniprogram/pages/read/read.js   resolved by devlop version
#	miniprogram/pages/referral/referral.js   resolved by devlop version
#	soul-api/internal/model/person.go   resolved by devlop version
This commit is contained in:
Alex-larget
2026-03-24 15:44:56 +08:00
127 changed files with 9196 additions and 3504 deletions

View 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 }

View File

@@ -0,0 +1,39 @@
/**
* MBTI 默认头像:与后台 system_config.mbti_avatars + GET /api/miniprogram/config/mbti-avatars 一致
*/
const MBTI_RE = /^[EI][NS][FT][JP]$/
function normalizeMbti(m) {
const s = (m && String(m).trim().toUpperCase()) || ''
return MBTI_RE.test(s) ? s : ''
}
/**
* 展示用头像:优先用户已设头像(补全相对路径),否则合法 MBTI + 映射表中有 URL 则用映射
* @param {string} avatar
* @param {string} mbti
* @param {Record<string,string>} map
* @param {string} baseUrl
*/
function resolveAvatarWithMbti(avatar, mbti, map, baseUrl) {
let a = (avatar && String(avatar).trim()) || ''
if (a) {
if (!/^https?:\/\//i.test(a) && baseUrl) {
if (a.startsWith('/')) a = baseUrl + a
}
return a
}
const key = normalizeMbti(mbti)
if (!key || !map || typeof map !== 'object') return ''
let u = (map[key] && String(map[key]).trim()) || ''
if (!u) return ''
if (!/^https?:\/\//i.test(u) && baseUrl && u.startsWith('/')) u = baseUrl + u
return u
}
module.exports = {
MBTI_RE,
normalizeMbti,
resolveAvatarWithMbti,
}

View 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 }

View File

@@ -0,0 +1,13 @@
/**
* 与管理端 content/ChapterTree.tsx 的 PART_ICONS、正文篇序规则一致
* 后台篇头用 emoji 轮询;小程序目录页与之对齐(无自定义图时)
*/
const PART_ICONS = ['📖', '📕', '📗', '📘', '📙', '📓', '📔', '📒', '📚', '📖']
/** 正文篇在列表中的从 0 开始的序号 → emoji与 ChapterTree bodyPartOrdinal 一致) */
function partEmojiForBodyIndex(bodyIndex) {
const i = Math.max(0, Number(bodyIndex) || 0)
return PART_ICONS[i % PART_ICONS.length]
}
module.exports = { PART_ICONS, partEmojiForBodyIndex }

View File

@@ -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) {

View File

@@ -1,10 +1,12 @@
/**
* 卡若创业派对 - 用户旅程规则引擎
* 从后端 /api/miniprogram/user-rules 读取启用的规则,按场景触发引导
* 从后端 /api/miniprogram/user-rules 读取启用的规则,按场景触发提示(文案偏利他、少用命令式)
* 稳定版兼容readCount 用 getReadCount()hasPurchasedFull 用 hasFullBook完善头像跳 avatar-nickname
*
* trigger → scene 映射:
* 注册 → after_login
* 注册 → after_login(头像或昵称未完善)
* update_avatar / 完善头像 → 仅头像未完善
* update_nickname / 修改昵称 → 仅昵称为默认
* 点击收费章节 → before_read
* 完成匹配 → after_match
* 完成付款 → after_pay
@@ -34,9 +36,12 @@ 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',
@@ -45,6 +50,10 @@ const TRIGGER_SCENE_MAP = {
'浏览导师页': '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 {
@@ -74,12 +83,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,36 +133,132 @@ 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)
}
// 稳定版:跳转 avatar-nickname专注头像+昵称,首次登录由 app.login 强制 redirect
// VIP 用户不触发:统一由 checkVipContactRequiredAndGuide 引导到 profile-edit避免与主流程冲突
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 avatar = user.avatar || user.avatarUrl || ''
const nickname = user.nickname || ''
if (avatar && !avatar.includes('default') && nickname && nickname !== '微信用户' && !nickname.startsWith('微信用户')) 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',
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'
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()
@@ -132,25 +269,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 +315,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 +374,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 +402,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'
}
@@ -255,15 +418,17 @@ function checkRulesSync(scene, rules) {
switch (scene) {
case 'after_login':
return checkRule_FillAvatar(rules)
return checkAvatarNicknameGuides(rules)
case 'before_read':
return checkRule_BindPhone(rules) || checkRule_FillAvatar(rules)
return checkRule_BindPhone(rules) || checkAvatarNicknameGuides(rules)
case 'before_pay':
return checkAvatarNicknameGuides(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':
return checkRule_FillAvatar(rules) || checkRule_ShareAfter5Chapters(rules) || checkRule_BindWechat(rules) || checkRule_Withdraw(rules)
return checkAvatarNicknameGuides(rules) || checkRule_ShareAfter5Chapters(rules) || checkRule_BindWechat(rules) || checkRule_Withdraw(rules)
case 'before_join_party':
return checkRule_JoinParty(rules)
default:
@@ -277,8 +442,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 +453,9 @@ function executeRule(rule, pageInstance) {
pageInstance.showPhoneBinding()
}
}
if (rule.serverRuleId) {
markRuleCompleted(rule.serverRuleId)
}
}
_trackRuleAction(rule.ruleId, res.confirm ? 'confirm' : 'cancel')
}
@@ -309,10 +477,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 }

View File

@@ -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,