feat: v1.3.0 差异化重构——星图点亮体系

P0 修复:
- 详情页正文渲染(content 分段),71 条知识不再空白
- 问答页去登录墙:游客每日 3 次(IP 限流),登录后每日 5 次
- 修正问答配额为真正的每日重置语义

点亮闭环:
- KnowledgeLit 模型 + 服务端判题(POST /:id/quiz,答案不下发)
- /api/knowledge/stats 星域统计 + 点亮进度 + streak + litIds
- AI 批量生成 71 条读后检测题(scripts/generate-quiz.js)

设计系统 2.0:
- 70 个统一线性图标替换全部 emoji 图标
- 主题 token mixin 化,修复 H5 暗色级联 bug,MP 跟随系统(darkmode)
- 首页星域导航(进度环)、我的页学习者档案、星域列表页重写
- 硬编码色清理,误植 map 页移除

服务端加固:
- trust proxy 真实 IP、错误信息脱敏、ask 独立限流、无效 ObjectId 404
- seo.js ESM→CJS(修复容器启动)、AI 导师"维度精灵"人格化
This commit is contained in:
2026-09-12 23:39:05 +08:00
parent cbc1a587f2
commit 2a111d21c0
104 changed files with 2776 additions and 2109 deletions
+153
View File
@@ -0,0 +1,153 @@
/**
* AI 批量生成知识检测题 —— 为没有 options 的知识生成 1 道四选一检测题
* 执行: node src/scripts/generate-quiz.js [--dry] [--limit 10]
*
* 生成后字段: question / options[{text,isCorrect}] / answer(解析)
* 幂等:已有 options 的知识自动跳过;单个失败不影响后续
*/
const mongoose = require('mongoose')
const path = require('path')
require('dotenv').config({ path: path.join(__dirname, '..', '..', '.env') })
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/wdkj'
const args = process.argv.slice(2)
const DRY = args.includes('--dry')
const LIMIT_ARG = args.find(a => a.startsWith('--limit'))
const LIMIT = LIMIT_ARG ? parseInt(LIMIT_ARG.split('=')[1] || args[args.indexOf(LIMIT_ARG) + 1]) : 0
async function getModels() {
const { AIModel } = require('../models')
const active = await AIModel.getActiveModels()
if (active && active.length) return active
return [{
name: 'env-model',
apiUrl: process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions',
apiKey: process.env.OPENAI_API_KEY,
modelId: process.env.OPENAI_MODEL || 'gpt-3.5-turbo'
}]
}
function extractJson(text) {
if (!text) return null
let t = text.trim()
const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/)
if (fence) t = fence[1].trim()
const start = t.indexOf('{')
const end = t.lastIndexOf('}')
if (start === -1 || end === -1) return null
try {
return JSON.parse(t.slice(start, end + 1))
} catch {
return null
}
}
function validate(q) {
if (!q || typeof q.question !== 'string' || !q.question.trim()) return null
if (!Array.isArray(q.options) || q.options.length < 3 || q.options.length > 5) return null
const normalized = q.options.map(o => ({ text: String(o.text || '').trim(), isCorrect: !!o.isCorrect }))
if (normalized.some(o => !o.text)) return null
const correctCount = normalized.filter(o => o.isCorrect).length
if (correctCount !== 1) return null
return {
question: q.question.trim(),
options: normalized,
answer: String(q.answer || '').trim()
}
}
async function askAI(models, prompt) {
for (const model of models) {
try {
const headers = { 'Content-Type': 'application/json' }
if (model.apiKey && model.apiKey !== 'sk-dummy' && model.apiKey !== '***') {
headers['Authorization'] = `Bearer ${model.apiKey}`
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 60000)
let res
try {
res = await fetch(model.apiUrl, {
method: 'POST',
headers,
body: JSON.stringify({
model: model.modelId,
messages: [
{ role: 'system', content: '你是知识测验出题官,只输出 JSON,不要输出任何其他文字。' },
{ role: 'user', content: prompt }
],
temperature: 0.4,
max_tokens: 600
}),
signal: controller.signal
})
} finally {
clearTimeout(timer)
}
if (!res.ok) continue
const data = await res.json()
const content = data.choices?.[0]?.message?.content || ''
return content
} catch (e) {
console.warn(` 模型 ${model.name} 失败: ${e.message}`)
}
}
return null
}
async function main() {
await mongoose.connect(MONGODB_URI)
console.log('✅ 已连接 MongoDB')
const { Knowledge } = require('../models')
const models = await getModels()
console.log(`🤖 出题模型: ${models.map(m => m.name).join(', ')}`)
const query = {
status: 'approved',
$or: [{ options: { $exists: false } }, { options: { $size: 0 } }, { question: { $exists: false } }, { question: null }]
}
let items = await Knowledge.find(query).sort({ dim: 1, sortOrder: 1 }).lean()
if (LIMIT > 0) items = items.slice(0, LIMIT)
console.log(`📚 待出题知识: ${items.length} 条${DRY ? '(dry 模式,不写库)' : ''}\n`)
let ok = 0
let fail = 0
for (const item of items) {
const prompt = `基于以下 AI 知识内容,出一道四选一的中文检测题(考察对核心概念的理解,干扰项要有迷惑性但不歧义)。
标题:${item.title}
内容:${item.content.slice(0, 800)}
严格按此 JSON 格式输出:
{"question":"题干","options":[{"text":"选项A","isCorrect":true},{"text":"选项B","isCorrect":false},{"text":"选项C","isCorrect":false},{"text":"选项D","isCorrect":false}],"answer":"一句话解析为什么正确"}`
console.log(`→ [dim${item.dim}] ${item.title}`)
const raw = await askAI(models, prompt)
const quiz = validate(extractJson(raw))
if (!quiz) {
console.log(' ✗ 生成或校验失败,跳过')
fail++
} else {
console.log(` ✓ ${quiz.question} (${quiz.options.length} 选项)`)
if (!DRY) {
await Knowledge.updateOne(
{ _id: item._id },
{ $set: { question: quiz.question, options: quiz.options, answer: quiz.answer } }
)
}
ok++
}
await new Promise(r => setTimeout(r, 400))
}
console.log(`\n完成:成功 ${ok},失败 ${fail},共 ${items.length}`)
await mongoose.disconnect()
}
main().catch(err => {
console.error('执行失败:', err)
process.exit(1)
})