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:
卡若
2026-03-23 18:38:23 +08:00
parent cb6e2bff56
commit fa3da12b16
82 changed files with 5621 additions and 2723 deletions

View File

@@ -9,6 +9,8 @@ const { checkAndExecute } = require('./utils/ruleEngine.js')
const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
const DEFAULT_MCH_ID = '1318592501'
const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
const PRODUCTION_BASE_URL = 'https://soulapi.quwanzhi.com'
const CONFIG_CACHE_KEY = 'mpConfigCacheV1'
// 与上传版本号对齐;设置页展示优先用 wx.getAccountInfoSync().miniProgram.version正式版否则用本字段
const APP_DISPLAY_VERSION = '1.7.1'
@@ -101,25 +103,26 @@ App({
/** 正式版强制生产 API避免误传 localhost 导致审核/线上全挂 */
initApiBaseUrl() {
const PRODUCTION = 'https://soulapi.quwanzhi.com'
const KEY = 'apiBaseUrl'
try {
const info = wx.getAccountInfoSync?.()
const env = info?.miniProgram?.envVersion || 'release'
if (env === 'release') {
this.globalData.baseUrl = PRODUCTION
this.globalData.baseUrl = PRODUCTION_BASE_URL
try {
const saved = wx.getStorageSync(KEY)
if (saved && saved !== PRODUCTION) wx.removeStorageSync(KEY)
if (saved && saved !== PRODUCTION_BASE_URL) wx.removeStorageSync(KEY)
} catch (_) {}
return
}
const saved = wx.getStorageSync(KEY)
if (saved && typeof saved === 'string' && /^https?:\/\//.test(saved)) {
this.globalData.baseUrl = String(saved).replace(/\/$/, '')
} else {
this.globalData.baseUrl = PRODUCTION_BASE_URL
}
} catch (_) {
this.globalData.baseUrl = PRODUCTION
this.globalData.baseUrl = PRODUCTION_BASE_URL
}
},
@@ -393,8 +396,8 @@ App({
if (!this.globalData.isSinglePageMode) return true
wx.showModal({
title: '请前往完整小程序',
content: '当前朋友圈单页,仅支持部分浏览。如需登录和解锁内容,请点击底部「前往小程序」后再操作。',
title: '请打开完整小程序',
content: '当前朋友圈预览,无法在这里登录或付款。请先点击屏幕底部「前往小程序」,进入完整版后再解锁本章。',
showCancel: false,
confirmText: '我知道了',
})
@@ -410,7 +413,7 @@ App({
},
/**
* 头像/昵称未改则引导:老用户弹窗后跳 avatar-nickname新用户由登录处强制 redirectTo
* 头像/昵称未改:老用户弹窗后跳 avatar-nickname新用户由登录处强制 redirectTo
* VIP 用户不在此处理,统一走 checkVipContactRequiredAndGuide 只跳 profile-edit避免乱跳
*/
checkAvatarNicknameAndGuide() {
@@ -429,10 +432,10 @@ App({
if (lastDate === today) return
wx.setStorageSync('lastAvatarGuideDate', today)
wx.showModal({
title: '完善个人资料',
content: '请设置头像昵称,让其他创业者更好地认识你',
confirmText: '去完善',
cancelText: '稍后',
title: '设置头像与昵称',
content: '头像昵称会出现在名片与匹配卡片上,方便伙伴认出你。',
confirmText: '去设置',
cancelText: '关闭',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/avatar-nickname/avatar-nickname' })
}
@@ -590,7 +593,7 @@ App({
/**
* VIP 用户登录后检测:手机号/头像昵称等需完善时,统一只跳 profile-edit避免与 avatar-nickname 乱跳。
* 旧数据VIP 但头像昵称未改):弹窗「为了更好服务,请完善资料」→ redirectTo profile-edit
* 旧数据VIP 但头像昵称未改):说明原因后 redirectTo profile-edit
*/
async checkVipContactRequiredAndGuide() {
if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return
@@ -619,12 +622,12 @@ App({
const wechatId = (profileData.wechatId || profileData.wechat_id || this.globalData.userInfo?.wechatId || this.globalData.userInfo?.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
const needsAvatarNickname = this._needsAvatarNickname(profileData)
// VIP 头像/昵称未改(含旧数据):统一只跳 profile-edit,弹窗「为了更好服务,请完善资料」
// VIP 头像/昵称未改(含旧数据):统一只跳 profile-edit
if (needsAvatarNickname) {
wx.showModal({
title: '完善资料',
content: '为了更好为您服务,请完善资料',
confirmText: '去完善',
title: '补全对外展示信息',
content: 'VIP 名片与派对场景会展示头像与昵称,补全后对方更容易认出你。',
confirmText: '去填写',
showCancel: false,
success: () => {
wx.redirectTo({ url: '/pages/profile-edit/profile-edit' })
@@ -637,9 +640,9 @@ App({
// VIP 无手机号:弹窗说明后跳转
if (!phone) {
wx.showModal({
title: '完善资料',
content: 'VIP会员需完善手机号以便使用找伙伴、提现等功能',
confirmText: '去完善',
title: '补全手机号',
content: '手机号用于找伙伴、提现验证与重要通知,仅本人可见。',
confirmText: '去填写',
showCancel: false,
success: () => {
wx.redirectTo({ url: '/pages/profile-edit/profile-edit' })
@@ -647,12 +650,12 @@ App({
})
return
}
// 有手机号但缺微信号:弹窗引导(非强制)
// 有手机号但缺微信号:可选补全(非强制)
wx.showModal({
title: '完善联系方式',
content: '请到资料页完善微信号,便于他人联系您',
confirmText: '去完善',
cancelText: '稍后',
title: '补全微信号(可选)',
content: '填写微信号后,对方在允许的场景下能更快加到你;不填也可继续使用。',
confirmText: '去填写',
cancelText: '关闭',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
}
@@ -691,6 +694,17 @@ App({
if (!forceRefresh && this.globalData.configCache && now < this.globalData.configCacheExpires) {
return this.globalData.configCache
}
if (!forceRefresh && !this.globalData.configCache) {
try {
const local = wx.getStorageSync(CONFIG_CACHE_KEY)
const exp = Number(local && local.expiresAt)
if (local && local.data && exp > now) {
this.globalData.configCache = local.data
this.globalData.configCacheExpires = exp
return local.data
}
} catch (_) {}
}
try {
const [coreRes, auditRes] = await Promise.all([
this.request({ url: '/api/miniprogram/config/core', silent: true, timeout: 5000 }),
@@ -709,11 +723,45 @@ App({
}
this.globalData.configCache = res
this.globalData.configCacheExpires = now + CACHE_TTL
try {
wx.setStorageSync(CONFIG_CACHE_KEY, {
data: res,
expiresAt: this.globalData.configCacheExpires
})
} catch (_) {}
return res
}
} catch (e) {
if (this.globalData.configCache) return this.globalData.configCache
}
} catch (_) {}
try {
const legacy = await this.request({ url: '/api/miniprogram/config', silent: true, timeout: 5000 })
if (legacy) {
const cfg = (legacy.configs && legacy.configs.mp_config) || legacy.mpConfig || {}
const res = {
success: legacy.success !== false,
prices: legacy.prices || (legacy.configs && legacy.configs.chapter_config && legacy.configs.chapter_config.prices) || {},
features: legacy.features || (legacy.configs && legacy.configs.feature_config) || {},
userDiscount: legacy.userDiscount,
mpConfig: cfg,
configs: legacy.configs || {}
}
this.globalData.configCache = res
this.globalData.configCacheExpires = now + CACHE_TTL
try {
wx.setStorageSync(CONFIG_CACHE_KEY, {
data: res,
expiresAt: this.globalData.configCacheExpires
})
} catch (_) {}
return res
}
} catch (_) {}
if (this.globalData.configCache) return this.globalData.configCache
try {
const local = wx.getStorageSync(CONFIG_CACHE_KEY)
if (local && local.data) return local.data
} catch (_) {}
return null
},
@@ -851,6 +899,79 @@ App({
return (msg && String(msg).trim()) ? String(msg).trim() : defaultMsg
},
_shouldFallbackToProduction(err) {
const msg = String((err && err.errMsg) || (err && err.message) || '').toLowerCase()
return (
msg.includes('connection_failed') ||
msg.includes('err_proxy_connection_failed') ||
msg.includes('dns') ||
msg.includes('name not resolved') ||
msg.includes('failed to fetch') ||
msg.includes('econnrefused') ||
msg.includes('network') ||
msg.includes('timeout')
)
},
_switchBaseUrlToProduction() {
this.globalData.baseUrl = PRODUCTION_BASE_URL
try {
wx.setStorageSync('apiBaseUrl', PRODUCTION_BASE_URL)
} catch (_) {}
},
_requestOnce(url, options = {}, silent = false) {
const showError = (msg) => {
if (!silent && msg) wx.showToast({ title: msg, icon: 'none', duration: 2500 })
}
return new Promise((resolve, reject) => {
const token = wx.getStorageSync('token')
wx.request({
url: this.globalData.baseUrl + url,
method: options.method || 'GET',
data: options.data || {},
timeout: options.timeout || 15000,
header: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
...options.header
},
success: (res) => {
const data = res.data
if (res.statusCode === 200) {
if (data && data.success === false) {
const msg = this._getApiErrorMsg(data, '操作失败')
if (msg && (msg.includes('用户不存在') || msg.toLowerCase().includes('user not found'))) {
this.logout()
}
showError(msg)
reject(new Error(msg))
return
}
resolve(data)
return
}
if (res.statusCode === 401) {
this.logout()
showError('未授权,请重新登录')
reject(new Error('未授权'))
return
}
const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败')
showError(msg)
reject(new Error(msg))
},
fail: (err) => {
const msg = (err && err.errMsg)
? (String(err.errMsg).indexOf('timeout') !== -1 ? '请求超时,请重试' : '网络异常,请重试')
: '网络异常,请重试'
showError(msg)
reject(err || new Error(msg))
}
})
})
},
/**
* 统一请求方法。接口失败时会弹窗提示(与 soul-api 返回的 message/error 一致)。
* GET 请求 200ms 内相同 url 去重,避免并发重复请求。
@@ -870,68 +991,21 @@ App({
}
const method = (options.method || 'GET').toUpperCase()
const silent = !!options.silent
const showError = (msg) => {
if (!silent && msg) {
wx.showToast({ title: msg, icon: 'none', duration: 2500 })
}
}
// GET 短时去重:相同 url 的并发请求共享同一 promise
if (method === 'GET') {
const dedupKey = url + (options.data ? JSON.stringify(options.data) : '')
const pending = this._requestPending || (this._requestPending = {})
if (pending[dedupKey]) {
return pending[dedupKey].promise
}
if (pending[dedupKey]) return pending[dedupKey].promise
}
const promise = new Promise((resolve, reject) => {
const token = wx.getStorageSync('token')
wx.request({
url: this.globalData.baseUrl + url,
method: options.method || 'GET',
data: options.data || {},
timeout: options.timeout || 15000,
header: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
...options.header
},
success: (res) => {
const data = res.data
if (res.statusCode === 200) {
// 业务失败success === falsesoul-api 用 message 或 error 返回原因
if (data && data.success === false) {
const msg = this._getApiErrorMsg(data, '操作失败')
// 登录态不一致:本地有 token/userInfo但后端查不到该用户
// 典型原因:切换环境(baseUrl)、换库/清库、用户被删除、token 与用户不匹配
if (msg && (msg.includes('用户不存在') || msg.toLowerCase().includes('user not found'))) {
this.logout()
}
showError(msg)
reject(new Error(msg))
return
}
resolve(data)
return
}
if (res.statusCode === 401) {
this.logout()
showError('未授权,请重新登录')
reject(new Error('未授权'))
return
}
// 4xx/5xx优先用返回体的 message/error
const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败')
showError(msg)
reject(new Error(msg))
},
fail: (err) => {
const msg = (err && err.errMsg) ? (err.errMsg.indexOf('timeout') !== -1 ? '请求超时,请重试' : '网络异常,请重试') : '网络异常,请重试'
showError(msg)
reject(new Error(msg))
}
})
const promise = this._requestOnce(url, options, silent).catch(async (err) => {
const currentBase = String(this.globalData.baseUrl || '').replace(/\/$/, '')
if (currentBase !== PRODUCTION_BASE_URL && this._shouldFallbackToProduction(err)) {
this._switchBaseUrlToProduction()
return this._requestOnce(url, options, silent)
}
const msg = (err && err.message) ? err.message : '网络异常,请重试'
throw new Error(msg)
})
if (method === 'GET') {
@@ -1169,6 +1243,22 @@ App({
wx.setStorageSync('readSectionIds', list)
},
/**
* 记录「最近打开过」某章节(含仅预览/未登录),供「我的」最近阅读展示;
* 不写入 readSectionIds避免把「点开预览」算进已读章节数。
*/
touchRecentSection(sectionId) {
if (!sectionId) return
try {
let list = wx.getStorageSync('recent_section_opens')
if (!Array.isArray(list)) list = []
const now = Date.now()
list = list.filter((x) => x && x.id !== sectionId)
list.unshift({ id: String(sectionId), t: now })
wx.setStorageSync('recent_section_opens', list.slice(0, 40))
} catch (_) {}
},
// 已读章节数(用于首页展示)
getReadCount() {
return (this.globalData.readSectionIds || []).length