feat: 同步今日小程序与后台迭代版本

集中提交今日 API、管理端、小程序与部署文档调整,确保 Gitea 主分支与本地最新开发版本一致。

Made-with: Cursor
This commit is contained in:
卡若
2026-04-20 11:07:55 +08:00
parent 7108f28280
commit ce55c48c64
180 changed files with 11659 additions and 6078 deletions

View File

@@ -0,0 +1,105 @@
/**
* 神仙 AI根据用户输入识别跳转「我的」同源能力匹配工作 / 了解自己 / 性格测试)
*/
function detectJobIntent(text) {
const s = String(text || '').trim()
if (!s) return false
return (
/工作|求职|就业|职业|岗位|职位|面试|跳槽|转行|offer|薪资|薪水|上班|打工|应聘|招聘|简历|适合.+工作|找.+工作|干什么工作|做什么工作|哪类工作|啥工作|职场|事业|工种/i.test(
s
) || /适合什么样|适合什么类型|MBTI.+工作|性格.+工作/i.test(s)
)
}
function detectSelfKnowIntent(text) {
const s = String(text || '').trim()
if (!s) return false
return /了解自己|认识自己|认识自我|内在|潜能|我是什么样|我是谁|读懂自己|深度了解|真实的我|本心|自我认知|探索自己|看清自己|个性|人格|我的特点|我是怎样的人/i.test(
s
)
}
/** 用户明确想做题 / 测评(与「了解自己」泛聊区分) */
function detectPersonalityTestIntent(text) {
const s = String(text || '').trim()
if (!s) return false
return /性格测试|人格测试|MBTI测试|测MBTI|做测评|做测试|测一测|问卷|量表|测题|测一下|重新测|再测/i.test(s)
}
/**
* 求职 > 测评 > 了解自己
* @returns {'job'|'test'|'self'|null}
*/
function resolveChatFeatureIntent(text, hasMbtiType) {
const s = String(text || '').trim()
if (!s) return null
if (detectJobIntent(s)) return 'job'
if (detectPersonalityTestIntent(s)) return 'test'
if (detectSelfKnowIntent(s)) return 'self'
return null
}
/** 助手回复是否与该功能意图相关(避免文不对题的推荐卡片) */
function replyMatchesFeatureIntent(intent, assistantText) {
const t = String(assistantText || '').trim()
if (!t) return false
if (intent === 'job') {
return /工作|职业|岗位|求职|就业|职场|行业|方向|规划|面试|转行|技能|发展|适配|资源|管理|团队|领导|MBTI|性格.*工作|适合.*工作/i.test(t)
}
if (intent === 'test') {
return /测试|测评|MBTI|问卷|维度|类型|题目|结果|人格|性格|指标|信度|效度/i.test(t)
}
if (intent === 'self') {
return /性格|人格|特质|优势|盲点|内在|情绪|状态|成长|了解自己|认识|适合|关系|伴侣|沟通|需求|模式/i.test(t)
}
return false
}
/**
* 「了解自己」类:随机打开 了解自己(购买页) 或 性格测试(选测页)。
* 尚无 MBTI 结果时略提高去「性格测试」的概率,便于先测评。
* @param {boolean} hasMbtiType 是否已有 MBTI 类型(与 ai-chat 页 mbtiType 一致)
* @returns {string} 小程序 path
*/
function pickSelfServicePath(hasMbtiType) {
try {
const app = getApp()
if (app && app.globalData && app.globalData.miniprogramAuditMode) {
return '/pages/test-select/index'
}
} catch (e) {}
const r = Math.random()
if (!hasMbtiType) {
return r < 0.65 ? '/pages/test-select/index' : '/pages/purchase/index'
}
return r < 0.5 ? '/pages/purchase/index' : '/pages/test-select/index'
}
/**
* 阅读数展示8630 + 距发布日天数×50≥1万 显示为「x万+」
* @param {string} publishedAt 如 2026-04-17
*/
function formatInlineReadLabel(publishedAt) {
const s = String(publishedAt || '').trim()
let ts = s ? Date.parse(s.replace(/\./g, '-')) : NaN
if (Number.isNaN(ts)) ts = Date.now()
const days = Math.max(0, Math.floor((Date.now() - ts) / 86400000))
const raw = 8630 + days * 50
if (raw >= 10000) {
const wan = raw / 10000
const rounded = Math.round(wan * 10) / 10
return `${rounded}万+`
}
return `${raw}阅读`
}
module.exports = {
detectJobIntent,
detectSelfKnowIntent,
detectPersonalityTestIntent,
resolveChatFeatureIntent,
replyMatchesFeatureIntent,
pickSelfServicePath,
formatInlineReadLabel
}

View File

@@ -0,0 +1,101 @@
/**
* 「了解自己」深度服务页:按 enterprisePermissions与 /api/config/runtime 一致)过滤套餐权益文案。
* permissions 为 null 时视为全开(个人未绑定企业)。
*/
const TEST_ORDER = [
{ key: 'mbti', label: 'MBTI' },
{ key: 'disc', label: 'DISC' },
{ key: 'pdp', label: 'PDP' },
{ key: 'sbti', label: 'SBTI' },
{ key: 'face', label: '面相' }
]
/** @param {Record<string, any>|null|undefined} p */
function isPermOn(key, p) {
if (!p || typeof p !== 'object') return true
return p[key] !== false
}
/**
* 顶部副标题里「结合 xxx 的综合解读」动态片段
* @param {Record<string, any>|null|undefined} perms
*/
function buildTestsLabelForHero(perms) {
const parts = []
for (const { key, label } of TEST_ORDER) {
if (isPermOn(key, perms)) parts.push(label)
}
if (parts.length === 0) return '定制测评组合'
return parts.join(' / ')
}
/**
* 完整 Hero 说明(第二行长文案)
* @param {Record<string, any>|null|undefined} perms
*/
function buildDeepHeroDesc(perms) {
const label = buildTestsLabelForHero(perms)
return `结合 ${label} 的综合解读;个人 1v1、团队工作坊、VIP 职业发展三档方案。提交后顾问会主动与你联系。`
}
/**
* 是否至少有一项性格/面相类能力开启(用于「综合报告」「职业」等泛化权益)
* @param {Record<string, any>|null|undefined} p
*/
function anyPersonalityOn(p) {
if (!p || typeof p !== 'object') return true
return TEST_ORDER.some(({ key }) => p[key] !== false)
}
// 单行权益与权限键:命中正则则受对应开关控制
const FEATURE_LINE_RULES = [
{ re: /面相|面部分析|面部|三张照片|照片\+问卷/i, key: 'face' },
{ re: /\bMBTI\b|MBTI性格|16型/i, key: 'mbti' },
{ re: /\bDISC\b|DISC沟通/i, key: 'disc' },
{ re: /\bPDP\b|PDP行为/i, key: 'pdp' },
{ re: /盖洛普|SBTI|sbti/i, key: 'sbti' }
]
/**
* @param {string[]} features
* @param {Record<string, any>|null|undefined} perms
* @returns {string[]}
*/
function filterFeatureLines(features, perms) {
if (!Array.isArray(features) || !features.length) return []
return features.filter((line) => {
const s = String(line || '').trim()
if (!s) return false
for (const { re, key } of FEATURE_LINE_RULES) {
if (re.test(s)) return isPermOn(key, perms)
}
if (/多维度|综合.*性格|优势解读|潜在盲区/i.test(s)) return anyPersonalityOn(perms)
if (/职业|发展方向|匹配.*职业/i.test(s)) return anyPersonalityOn(perms)
return true
})
}
/**
* @param {any[]} categories
* @param {Record<string, any>|null|undefined} perms
*/
function filterCategories(categories, perms) {
if (!Array.isArray(categories)) return []
return categories.map((cat) => {
const copy = Object.assign({}, cat)
if (Array.isArray(copy.features)) {
copy.features = filterFeatureLines(copy.features, perms)
}
return copy
})
}
module.exports = {
buildTestsLabelForHero,
buildDeepHeroDesc,
filterFeatureLines,
filterCategories,
isPermOn,
anyPersonalityOn
}

View File

@@ -0,0 +1,46 @@
/**
* 超管审核相关:隐藏神仙 AI Tab/入口;提审模式下另由后端/各页关闭虚拟商品与「了解自己」深度套餐。
* - miniprogramAuditMode提审专用含虚拟支付合规
* - maintenanceMode / reviewMode面相审核用户口语「审核模式」常指其一
*/
function isAuditHideAiMode(gd) {
if (!gd) return false
return !!(gd.miniprogramAuditMode || gd.maintenanceMode || gd.reviewMode)
}
function redirectIfMiniprogramAudit(message) {
const app = getApp()
const gd = app && app.globalData
if (!isAuditHideAiMode(gd)) return false
wx.showToast({ title: message || '功能升级中', icon: 'none' })
setTimeout(() => {
wx.switchTab({ url: '/pages/index/index' })
}, 300)
return true
}
/**
* 先拉 runtime若存在再若提审则踢回首页否则执行 callback。
*/
function ensureRuntimeThenGate(callback) {
const app = getApp()
const run = () => {
if (isAuditHideAiMode(app.globalData)) {
redirectIfMiniprogramAudit()
return
}
if (typeof callback === 'function') callback()
}
if (app && typeof app.getRuntimeConfig === 'function') {
app.getRuntimeConfig().then(run).catch(run)
return
}
run()
}
module.exports = {
isAuditHideAiMode,
redirectIfMiniprogramAudit,
ensureRuntimeThenGate
}

View File

@@ -71,6 +71,13 @@ function generateOrderId(productType) {
function wxPay(options) {
const { orderId, amount = 0, description, productType, testResultId, deepProductId, enterpriseId, success, fail } = options
if (app.globalData && app.globalData.miniprogramAuditMode) {
const msg = '版本审核期间不可发起支付'
wx.showToast({ title: msg, icon: 'none' })
if (typeof fail === 'function') fail(new Error(msg))
return
}
try {
const analyticsMod = require('./analytics')
if (analyticsMod && typeof analyticsMod.track === 'function') {

View File

@@ -3,6 +3,67 @@
* 手机号与个人资料:详见 isProfileComplete 规则
*/
/** 本会话/本机「已用手机号授权登录」标记(与是否库里已有手机无关;换 userId 自动失效) */
const PHONE_LOGIN_SESSION_KEY = 'mbti_phone_login_v1'
const PHONE_LOGIN_TTL_MS = 90 * 86400000
function getLoginUserId() {
try {
const app = getApp()
const u = (app && app.globalData && app.globalData.userInfo) || wx.getStorageSync('userInfo')
if (!u || typeof u !== 'object') return ''
const id = u.id != null ? u.id : u.userId
return id != null && id !== '' ? String(id) : ''
} catch (e) {
return ''
}
}
/**
* 与当前登录用户一致且未过期时,视为已在结果页完成过「手机登录」授权
* @returns {boolean}
*/
function hasPhoneLoginSession() {
const uid = getLoginUserId()
if (!uid) return false
try {
const row = wx.getStorageSync(PHONE_LOGIN_SESSION_KEY)
if (!row || typeof row !== 'object') return false
if (String(row.uid || '') !== uid) return false
const at = Number(row.at || 0)
if (at > 0 && Date.now() - at > PHONE_LOGIN_TTL_MS) return false
return true
} catch (e) {
return false
}
}
function markPhoneLoginSession() {
const uid = getLoginUserId()
if (!uid) return
try {
wx.setStorageSync(PHONE_LOGIN_SESSION_KEY, { uid, at: Date.now() })
} catch (e) {}
}
function clearPhoneLoginSession() {
try {
wx.removeStorageSync(PHONE_LOGIN_SESSION_KEY)
} catch (e) {}
}
/** 静默登录写入新用户后调用userId 变化则清掉旧会话标记 */
function syncPhoneLoginStorageWithUser(user) {
const uid = user && (user.id != null ? String(user.id) : (user.userId != null ? String(user.userId) : ''))
if (!uid) return
try {
const row = wx.getStorageSync(PHONE_LOGIN_SESSION_KEY)
if (row && row.uid && String(row.uid) !== uid) {
wx.removeStorageSync(PHONE_LOGIN_SESSION_KEY)
}
} catch (e) {}
}
/**
* 个人资料是否已满足业务门禁(避免反复跳转「完善资料」)
* - 已绑定手机号:视为可用(付费/深度服务以手机为准;仅首字头像无 URL 不再卡死)
@@ -33,6 +94,25 @@ function isReportProfileComplete() {
return nickname.length > 0 && avatar.length > 0 && phone.length > 0
}
/**
* 问卷/结果页预览门禁:分享落地不遮挡;否则需手机号+昵称+头像齐全后才可看全文
*/
function needsResultProfileGate(fromShare) {
if (fromShare) return false
return !isReportProfileComplete()
}
/**
* 结果页 getPhoneNumber 成功后:若资料未齐,进入「我的-资料」与查看全文同一套规则
*/
function navigateToCompleteProfileAfterPhoneIfNeeded() {
if (isReportProfileComplete()) return
wx.showToast({ title: '请完善头像与昵称', icon: 'none' })
setTimeout(() => {
wx.navigateTo({ url: '/pages/user-profile/index?from=result_gate', fail: () => {} })
}, 450)
}
/**
* 若资料未完善则跳转到个人资料页,需登录
* @returns {boolean} true=已完善可继续false=已跳转
@@ -104,6 +184,7 @@ function bindPhoneByCode(code) {
const newUser = { ...user, phone }
app.globalData.userInfo = newUser
wx.setStorageSync('userInfo', newUser)
markPhoneLoginSession()
wx.showToast({ title: '授权成功', icon: 'success' })
resolve(newUser)
} else {
@@ -121,10 +202,26 @@ function bindPhoneByCode(code) {
})
}
/**
* 测评提交后直接进结果页;完整报告是否在结果页展示由「资料齐全」门禁控制(见 needsResultProfileGate / isReportProfileComplete
* @param {string} targetUrl 必须以 / 开头的本地路径,如 /pages/result/mbti?id=1&type=mbti
*/
function afterTestSubmitNavigate(targetUrl) {
const url = (targetUrl && String(targetUrl).trim()) || '/pages/index/index'
wx.redirectTo({ url, fail: () => wx.reLaunch({ url: '/pages/index/index' }) })
}
module.exports = {
hasPhone,
bindPhoneByCode,
isProfileComplete,
isReportProfileComplete,
needsResultProfileGate,
navigateToCompleteProfileAfterPhoneIfNeeded,
hasPhoneLoginSession,
markPhoneLoginSession,
clearPhoneLoginSession,
syncPhoneLoginStorageWithUser,
ensureProfileCompleteAndRedirect,
afterTestSubmitNavigate,
}

View File

@@ -0,0 +1,27 @@
/**
* 精选推荐 → 跳转其他小程序 path与「一场 soul」阅读页 pages/read/read?id= 对齐)
* @param {string} basePath 后台配置的 path无 sourceId 时使用
* @param {{ id?: string|number, title?: string, sourceId?: string|number, fromTag?: string }} opts
*/
function buildRecoMiniProgramPath(basePath, opts) {
const id = opts && opts.id != null ? String(opts.id).trim() : ''
const title = opts && opts.title != null ? String(opts.title).trim() : ''
const sourceId = opts && opts.sourceId != null ? String(opts.sourceId).trim() : ''
const fromTag = (opts && opts.fromTag) || 'mbti_reco'
const parts = ['from=' + encodeURIComponent(fromTag)]
if (id) parts.push('mbtiArticleId=' + encodeURIComponent(id))
if (title) parts.push('title=' + encodeURIComponent(title.slice(0, 200)))
if (sourceId) {
let path = 'pages/read/read?id=' + encodeURIComponent(sourceId)
return path + '&' + parts.join('&')
}
let path = (basePath || 'pages/index/index').replace(/^\//, '')
if (!path) path = 'pages/index/index'
const sep = path.indexOf('?') >= 0 ? '&' : '?'
return path + sep + parts.join('&')
}
module.exports = {
buildRecoMiniProgramPath
}

View File

@@ -1,6 +1,6 @@
/**
* 统一请求封装:自动拼接 baseURL、携带 token、处理 401
* 使用前需已执行 App(),否则 getApp() 在 require 时可能未就绪,这里在请求时再取 app
* 401 时先尝试静默登录一次并重试,减少 token 过期/首屏竞态导致的误 401
*/
function getAppSafe() {
try {
@@ -12,7 +12,17 @@ function getAppSafe() {
function getToken() {
const app = getAppSafe()
return (app && app.globalData && app.globalData.token) || wx.getStorageSync('token') || ''
const g = app && app.globalData && app.globalData.token
if (g) return g
try {
const st = wx.getStorageSync('token')
if (st && app && app.globalData) {
app.globalData.token = st
}
return st || ''
} catch (e) {
return ''
}
}
function getApiBase() {
@@ -20,9 +30,6 @@ function getApiBase() {
return (app && app.globalData && app.globalData.apiBase) || ''
}
/**
* 清除登录态401 或主动退出时调用)
*/
function clearLoginState() {
const app = getAppSafe()
if (app && app.globalData) {
@@ -37,17 +44,29 @@ function clearLoginState() {
}
/**
* 发起请求
* @param {Object} options - 同 wx.requesturl 可为相对路径(自动加 apiBase
* @param {boolean} options.needAuth - 是否携带 Authorization默认 true
* @param {boolean} options.allow401 - 401 时是否静默清除登录态而不 fail,默认 true
* @param {Object} options - 同 wx.request
* @param {boolean} options.needAuth - 是否带 Authorization默认 true
* @param {boolean} options.optionalAuth
* @param {boolean} options.allow401 - 401 且已带 Bearer 时是否清登录态(重试失败后),默认 true
*/
function request(options) {
const apiBase = getApiBase()
const url = options.url
const fullUrl = url.startsWith('http') ? url : `${apiBase.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`
const {
__didAuthRetry,
success: userSuccess,
fail: userFail,
complete: userComplete,
url: optUrl,
...rest
} = options
const fullUrl = optUrl.startsWith('http')
? optUrl
: `${apiBase.replace(/\/$/, '')}${optUrl.startsWith('/') ? '' : '/'}${optUrl}`
const needAuth = options.needAuth !== false
const optionalAuth = options.optionalAuth === true
const allow401 = options.allow401 !== false
const didRetry = __didAuthRetry === true
const header = {
'Content-Type': 'application/json',
@@ -56,34 +75,58 @@ function request(options) {
if (needAuth) {
const token = getToken()
if (token) header['Authorization'] = `Bearer ${token}`
} else if (optionalAuth) {
const token = getToken()
if (token) header['Authorization'] = `Bearer ${token}`
}
const success = options.success
const fail = options.fail
const complete = options.complete
const authHeaderSent = !!header['Authorization']
return wx.request({
...options,
wx.request({
...rest,
url: fullUrl,
header,
success(res) {
if (res.statusCode === 401 && allow401) {
if (
res.statusCode === 401 &&
allow401 &&
authHeaderSent &&
needAuth &&
!didRetry
) {
const app = getAppSafe()
if (app && typeof app.silentLogin === 'function') {
app
.silentLogin()
.then((loginOk) => {
if (loginOk && getToken()) {
request({ ...options, __didAuthRetry: true })
} else {
clearLoginState()
if (userSuccess) userSuccess(res)
}
})
.catch(() => {
clearLoginState()
if (userSuccess) userSuccess(res)
})
return
}
}
if (res.statusCode === 401 && allow401 && authHeaderSent) {
clearLoginState()
}
if (success) success(res)
if (userSuccess) userSuccess(res)
},
fail(err) {
if (fail) fail(err)
if (userFail) userFail(err)
},
complete(res) {
if (complete) complete(res)
if (userComplete) userComplete(res)
}
})
}
/**
* Promise 版 request便于 async/await
*/
function requestPromiseOnce(options) {
return new Promise((resolve, reject) => {
request({
@@ -110,7 +153,6 @@ function requestPromiseOnce(options) {
}
},
fail(err) {
// wx.request fail网络不可达 / CONNECTION_CLOSED / TLS / 超时
const e = new Error((err && err.errMsg) || '网络异常')
e.isNetworkError = true
reject(e)
@@ -119,23 +161,20 @@ function requestPromiseOnce(options) {
})
}
/**
* 带指数退避重试(默认 2 次):仅对"网络异常"或 5xx 重试4xx 不重试
* 用法requestPromise({ url, ...opts, retry: 2, retryDelayMs: 400 })
*/
function requestPromise(options) {
const retry = options.retry == null ? 2 : Number(options.retry) || 0
const retryDelayMs = options.retryDelayMs == null ? 400 : Number(options.retryDelayMs) || 0
let attempt = 0
const run = () => requestPromiseOnce(options).catch((err) => {
const retriable = err && (err.isNetworkError || (err.statusCode >= 500 && err.statusCode < 600))
if (!retriable || attempt >= retry) {
return Promise.reject(err)
}
attempt += 1
const wait = retryDelayMs * Math.pow(2, attempt - 1)
return new Promise((r) => setTimeout(r, wait)).then(run)
})
const run = () =>
requestPromiseOnce(options).catch((err) => {
const retriable = err && (err.isNetworkError || (err.statusCode >= 500 && err.statusCode < 600))
if (!retriable || attempt >= retry) {
return Promise.reject(err)
}
attempt += 1
const wait = retryDelayMs * Math.pow(2, attempt - 1)
return new Promise((r) => setTimeout(r, wait)).then(run)
})
return run()
}

View File

@@ -1,6 +1,6 @@
/**
* 结果页「按步骤解锁」状态:
* Step 1 · 看全文(需完善资料或付费)
* Step 1 · 看全文(需手机号+头像+昵称齐全,或已付费)
* Step 2 · 分享朋友圈Step 1 完成后启用)
* Step 3 · AI 拍照测试Step 2 完成后启用)
*
@@ -27,7 +27,7 @@ function write(obj) {
/**
* Step 1 是否已解锁
* 条件:资料完善profileGate===false免费或已付费
* 条件:资料已齐profileGate===false,与 isReportProfileComplete 一致)且(免费或已付费)
*/
function isStep1Unlocked({ profileGate, payRequired, isPaid }) {
if (profileGate) return false

View File

@@ -1,12 +1,12 @@
/**
* 结果页资料门禁:未完善头像+昵称+手机时,客户端展示约 30% 预览(与后端 locked 互补)
* 结果页门禁:非分享落地且资料未齐(手机+头像+昵称)时展示约 30% 预览
*/
const { isReportProfileComplete } = require('./phoneAuth.js')
const { needsResultProfileGate } = require('./phoneAuth.js')
/** @param {WechatMiniprogram.Page.TrivialInstance} page */
function setProfileGateOnPage(page) {
const fromShare = !!(page.data && page.data.fromShare)
const gate = !fromShare && !isReportProfileComplete()
const gate = needsResultProfileGate(fromShare)
page.setData({ profileGate: gate })
}
@@ -50,7 +50,6 @@ function openTimelineShareHint() {
}
module.exports = {
isReportProfileComplete,
setProfileGateOnPage,
slicePreviewText,
slicePreviewList,