feat: SBTI 测评接入与管理端题目/财务/用户联动

1、修复了用户详情、定价与题目管理、财务与分销等管理端展示与接口问题。

2、新增 SBTI 小程序测评/结果页、sbtiData 引擎、题库 SQL 与脚本;admin sbtiDisplay。

3、优化 AppConfig/CrmReport/Test、历史与个人中心、test-select 与支付相关逻辑。

Made-with: Cursor
This commit is contained in:
Ghost
2026-04-11 16:16:32 +08:00
parent 1a66e77ab7
commit 6fec2965aa
50 changed files with 4189 additions and 1259 deletions

View File

@@ -0,0 +1,172 @@
/**
* SBTI 计分与匹配(与 aisbti.com 公开页逻辑一致)
* @param {Array<{id:number,dimension:string,question:string,options:Array}>} questions 题库全部行(含 DG1/DG2
* @param {Record<number|string, number>} answers questionId -> 选项 value
*/
const data = require('./sbtiData.js')
/** 每维 2 题、每题 13 分 → 分和约 26据此划 L/M/H非「满分 6 分」) */
function sumToLevel(score) {
if (score <= 3) return 'L'
if (score === 4) return 'M'
return 'H'
}
function levelNum(level) {
return { L: 1, M: 2, H: 3 }[level] || 1
}
function parsePattern(pattern) {
return pattern.replace(/-/g, '').split('')
}
function shuffle(arr) {
const a = arr.slice()
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
/**
* 组卷:打乱计分题,随机插入爱好闸口;饮酒时出现 DG2由页面在 DG1=3 时插入)
*/
function buildShuffledPaper(allQuestions) {
const scoring = allQuestions.filter((q) => q.dimension && !['DG1', 'DG2'].includes(q.dimension))
const dg1 = allQuestions.find((q) => q.dimension === 'DG1')
const dg2 = allQuestions.find((q) => q.dimension === 'DG2')
const shuffled = shuffle(scoring)
const insertIndex = Math.floor(Math.random() * shuffled.length) + 1
const ordered = [...shuffled.slice(0, insertIndex)]
if (dg1) ordered.push(dg1)
ordered.push(...shuffled.slice(insertIndex))
return { ordered, dg1, dg2 }
}
/** 当前可见题序:选「饮酒」后在闸口题后插入 DG2 */
function getVisibleQuestions(ordered, answers, dg2) {
const visible = ordered.slice()
if (!dg2) return visible
if (visible.some((q) => q.id === dg2.id)) return visible
const gateIdx = visible.findIndex((q) => q.dimension === 'DG1')
if (gateIdx !== -1 && Number(answers[visible[gateIdx].id]) === 3) {
visible.splice(gateIdx + 1, 0, dg2)
}
return visible
}
function computeSbtiResult(questions, answers) {
const { TYPE_LIBRARY, NORMAL_TYPES, DIM_EXPLANATIONS, dimensionOrder } = data
const rawScores = {}
dimensionOrder.forEach((dim) => {
rawScores[dim] = 0
})
questions.forEach((q) => {
const dim = q.dimension
if (!dim || dim === 'DG1' || dim === 'DG2') return
if (!dimensionOrder.includes(dim)) return
const v = answers[q.id]
if (v == null || v === '') return
rawScores[dim] += Number(v) || 0
})
const levels = {}
Object.entries(rawScores).forEach(([dim, score]) => {
levels[dim] = sumToLevel(score)
})
const userVector = dimensionOrder.map((dim) => levelNum(levels[dim]))
const ranked = NORMAL_TYPES.map((type) => {
const vector = parsePattern(type.pattern).map(levelNum)
let distance = 0
let exact = 0
for (let i = 0; i < vector.length; i++) {
const diff = Math.abs(userVector[i] - vector[i])
distance += diff
if (diff === 0) exact += 1
}
const similarity = Math.max(0, Math.round((1 - distance / 30) * 100))
return { ...type, ...TYPE_LIBRARY[type.code], distance, exact, similarity }
}).sort((a, b) => {
if (a.distance !== b.distance) return a.distance - b.distance
if (b.exact !== a.exact) return b.exact - a.exact
return b.similarity - a.similarity
})
const bestNormal = ranked[0]
let dg2Id = null
questions.forEach((q) => {
if (q.dimension === 'DG2') dg2Id = q.id
})
const drunkTriggered = dg2Id != null && Number(answers[dg2Id]) === 2
let finalType
let modeKicker = '你的主类型'
let badge = `匹配度 ${bestNormal.similarity}% · 精准命中 ${bestNormal.exact}/15 维`
let sub = '维度命中度较高,当前结果可视为你的第一人格画像。'
let special = false
let secondaryType = null
if (drunkTriggered) {
finalType = { ...TYPE_LIBRARY.DRUNK }
secondaryType = bestNormal
modeKicker = '隐藏人格已激活'
badge = '匹配度 100% · 酒精异常因子已接管'
sub = '乙醇亲和性过强,系统已直接跳过常规人格审判。'
special = true
} else if (bestNormal.similarity < 60) {
finalType = { ...TYPE_LIBRARY.HHHH }
modeKicker = '系统强制兜底'
badge = `标准人格库最高匹配仅 ${bestNormal.similarity}%`
sub = '标准人格库对你的脑回路集体罢工了,于是系统把你强制分配给了 HHHH。'
special = true
} else {
finalType = { ...bestNormal }
}
/** 供结果页「匹配度 / 精准命中」展示(与 badge 文案一致) */
let matchPercent = bestNormal.similarity
let hitDimCount = bestNormal.exact
if (drunkTriggered) {
matchPercent = 100
hitDimCount = 15
}
const dimExplainList = dimensionOrder.map((dim) => ({
dim,
name: data.dimensionMeta[dim].name,
model: data.dimensionMeta[dim].model,
level: levels[dim],
raw: rawScores[dim],
text: DIM_EXPLANATIONS[dim][levels[dim]]
}))
return {
rawScores,
levels,
ranked,
bestNormal,
finalType,
modeKicker,
badge,
sub,
special,
secondaryType,
dimExplainList,
sbtiType: finalType.code,
sbtiCn: finalType.cn,
intro: finalType.intro,
desc: finalType.desc,
matchPercent,
hitDimCount
}
}
module.exports = {
computeSbtiResult,
buildShuffledPaper,
getVisibleQuestions,
TYPE_IMAGES: data.TYPE_IMAGES
}