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:
@@ -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 === false,soul-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
|
||||
|
||||
Reference in New Issue
Block a user