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
+102 -131
View File
@@ -2,124 +2,134 @@ const express = require('express')
const router = express.Router()
const { AIChatQuota, AIModel } = require('../models')
const ApiResponse = require('../utils/response')
const { userAuthMiddleware } = require('../middleware/auth')
const { userAuthMiddleware, optionalAuthMiddleware } = require('../middleware/auth')
const logger = require('../utils/logger')
// 游客每日免费问答次数(按 IP)
const GUEST_DAILY_QUOTA = 3
// AI 问答接口独立限流(按 IP,防刷成本)
const askRateLimit = require('express-rate-limit')({
windowMs: 60 * 60 * 1000,
max: 30,
standardHeaders: true,
message: { success: false, error: '提问太频繁了,休息一下再试试' }
})
/**
* 获取用户AI问答配额
* 获取或创建配额记录(登录用户 / 游客共用)
*/
async function getOrCreateQuota(openid, isGuest) {
let quota = await AIChatQuota.findOne({ openid })
if (!quota) {
quota = new AIChatQuota(isGuest ? { openid, freeQuota: GUEST_DAILY_QUOTA } : { openid })
await quota.save()
}
return quota
}
/**
* 获取用户AI问答配额(登录用户与游客均可查询)
* GET /api/ai-chat/quota
*/
router.get('/quota', userAuthMiddleware, async (req, res) => {
router.get('/quota', optionalAuthMiddleware, async (req, res) => {
try {
const openid = req.openid || req.user?.openid
const isGuest = !req.user
const openid = isGuest ? `guest:${req.ip}` : (req.openid || req.user?.openid)
if (!openid) {
return ApiResponse.error(res, '无法获取用户标识', 400)
}
let quota = await AIChatQuota.findOne({ openid })
if (!quota) {
// 创建新的配额记录,默认5次免费
quota = new AIChatQuota({ openid })
await quota.save()
}
// 检查是否需要重置每日使用次数
const quota = await getOrCreateQuota(openid, isGuest)
quota.resetDailyUsed()
await quota.save()
return ApiResponse.success(res, {
freeQuota: quota.freeQuota,
sharedQuota: quota.sharedQuota,
usedQuota: quota.usedQuota,
remainingQuota: quota.getRemainingQuota(),
dailyUsed: quota.dailyUsed
dailyUsed: quota.dailyUsed,
isGuest
})
} catch (error) {
logger.error('获取AI问答配额失败:', error)
return ApiResponse.serverError(res, error.message)
return ApiResponse.serverError(res, '获取配额失败')
}
})
/**
* AI问答接口
* AI问答接口(支持游客体验)
* POST /api/ai-chat/ask
*/
router.post('/ask', userAuthMiddleware, async (req, res) => {
router.post('/ask', askRateLimit, optionalAuthMiddleware, async (req, res) => {
try {
const openid = req.openid || req.user?.openid
const isGuest = !req.user
const openid = isGuest ? `guest:${req.ip}` : (req.openid || req.user?.openid)
const { question, dimension = 'general' } = req.body
if (!openid) {
return ApiResponse.error(res, '无法获取用户标识', 400)
}
if (!question || question.trim().length === 0) {
return ApiResponse.error(res, '请输入问题', 400)
}
// 检查用户配额
let quota = await AIChatQuota.findOne({ openid })
if (!quota) {
quota = new AIChatQuota({ openid })
await quota.save()
if (question.length > 500) {
return ApiResponse.error(res, '问题太长了,请精简到 500 字以内', 400)
}
const quota = await getOrCreateQuota(openid, isGuest)
if (!quota.hasQuota()) {
return ApiResponse.error(res, '问答次数已用完,分享好友可获得更多次数', 403)
const msg = isGuest
? `今日 ${GUEST_DAILY_QUOTA} 次游客体验已用完,登录后每天可问 5 次`
: '今日问答次数已用完,分享好友可获得更多次数'
return ApiResponse.error(res, msg, 403, { isGuest, needLogin: isGuest })
}
// 获取所有启用的AI模型(用于主备切换)
const aiModels = await AIModel.getActiveModels()
if (!aiModels || aiModels.length === 0) {
// 使用环境变量配置的模型
const envModels = [{
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',
config: { temperature: 0.7, maxTokens: 800, topP: 1 }
}]
return await tryModels(envModels, question, dimension, res, quota)
return await tryModels(envModels, question, dimension, res, quota, isGuest)
}
return await tryModels(aiModels, question, dimension, res, quota)
return await tryModels(aiModels, question, dimension, res, quota, isGuest)
} catch (error) {
logger.error('AI问答失败:', error)
return ApiResponse.serverError(res, error.message)
return ApiResponse.serverError(res, 'AI 服务暂时不可用')
}
})
/**
* 遍历模型列表尝试问答(主备切换)
*/
async function tryModels(models, question, dimension, res, quota) {
const systemPrompt = getSystemPromptByDimension(dimension)
async function tryModels(models, question, dimension, res, quota, isGuest) {
const systemPrompt = getSystemPrompt(dimension)
let lastError = null
for (const model of models) {
try {
const apiUrl = model.apiUrl
const apiKey = model.apiKey
const modelId = model.modelId
const modelConfig = model.config || {}
// 检查是否需要 Authorization(免费/无需key的API跳过)
const needsAuth = apiKey && apiKey !== 'sk-dummy' && apiKey !== '***'
// 构建请求头
const headers = { 'Content-Type': 'application/json' }
if (needsAuth) {
headers['Authorization'] = `Bearer ${apiKey}`
}
logger.info(`[AI Chat] 尝试模型: ${model.name} (${modelId})`)
// 调用AI API(带超时,避免外部服务无响应导致请求挂起)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 30000)
let response
@@ -142,134 +152,95 @@ async function tryModels(models, question, dimension, res, quota) {
} finally {
clearTimeout(timer)
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
logger.warn(`[AI Chat] 模型 ${model.name} 失败:`, response.status, errorData)
lastError = { status: response.status, data: errorData }
continue // 尝试下一个模型
lastError = { status: response.status }
continue
}
const data = await response.json()
const message = data.choices[0].message
// 兼容标准 content 与推理模型(sensenova reasoning / nvidia reasoning_content)
const answer = message.content || message.reasoning_content || message.reasoning || ''
// 扣除一次使用次数
if (!answer) {
lastError = { message: 'empty answer' }
continue
}
await quota.useQuota()
logger.info(`[AI Chat] 模型 ${model.name} 回答成功`)
return ApiResponse.success(res, {
answer,
remainingQuota: quota.getRemainingQuota()
remainingQuota: quota.getRemainingQuota(),
isGuest
})
} catch (err) {
logger.warn(`[AI Chat] 模型 ${model.name} 异常:`, err.message)
lastError = { message: err.message }
continue // 尝试下一个模型
continue
}
}
// 所有模型都失败了
logger.error('所有AI模型均失败:', lastError)
return ApiResponse.error(res, 'AI服务暂时不可用,请稍后再试', 500)
}
/**
* 分享获得问答次数
* 分享获得问答次数(仅登录用户)
* POST /api/ai-chat/share-gain
*/
router.post('/share-gain', userAuthMiddleware, async (req, res) => {
try {
const openid = req.openid || req.user?.openid
if (!openid) {
return ApiResponse.error(res, '无法获取用户标识', 400)
}
let quota = await AIChatQuota.findOne({ openid })
if (!quota) {
quota = new AIChatQuota({ openid })
}
// 增加5次分享获得的次数
const remainingQuota = await quota.addQuotaByShare(5)
return ApiResponse.success(res, {
message: '分享成功,获得5次问答机会',
gainedQuota: 5,
remainingQuota
})
} catch (error) {
logger.error('分享获得次数失败:', error)
return ApiResponse.serverError(res, error.message)
return ApiResponse.serverError(res, '操作失败')
}
})
/**
* 根据维度获取系统提示词
* AI 导师人格 —— 维度精灵
* 统一口吻:亲切、口语化、善用比喻,回答精炼,结尾给探索钩子
*/
function getSystemPromptByDimension(dimension) {
const prompts = {
dim1: `你是"宇之然AI维度"的AI助手,专门解答关于 AI 起源和历史的问题。
请用通俗易懂的语言解释人工智能发展史上的重要事件、人物和里程碑,如:
- 图灵测试(1950)与艾伦·图灵的贡献
- 达特茅斯会议(1956)——AI 的诞生
- 深蓝 vs 卡斯帕罗夫(1997)
- AlphaGo 与深度学习革命(2016)
- GPT 系列与大模型时代的开启
回答要简洁有趣,适合普通用户理解。`,
const PERSONA = `你是"宇之然AI维度"的 AI 导师「维度精灵」,住在五大知识星域之间,帮助用户探索人工智能。
你的说话风格:
- 亲切口语化,像朋友聊天,善用生活化比喻解释复杂概念
- 回答精炼:先一句话给结论,再展开 2-4 个要点,总共不超过 300 字
- 偶尔使用 ✦ 符号点缀,但不要堆砌
- 回答结尾可以自然地引导用户去相关星域继续探索(例如"想深入了解 XX,可以去『AI 发展』星域逛逛")
- 不确定的内容坦诚说明,不编造`
dim2: `你是"宇之然AI维度"的AI助手,专门解答关于 AI 技术发展的问题。
请用通俗易懂的语言解释人工智能核心技术演进,如:
- 机器学习与深度学习的基本原理
- Transformer 架构与自注意力机制
- 大规模预训练模型(GPT、BERT等)
- 多模态 AI、MoE 架构
- Agent 智能体技术
回答要简洁有趣,适合普通用户理解。`,
const DIM_HINTS = {
dim1: '用户正在探索「AI 起源」星域(图灵测试、达特茅斯会议、深度学习革命等历史),结合这个语境回答。',
dim2: '用户正在探索「AI 发展」星域(机器学习、Transformer、大模型、Agent 等技术演进),结合这个语境回答。',
dim3: '用户正在探索「AI 当前」星域(2026 行业格局、大模型竞争、具身智能等),结合这个语境回答。',
dim4: '用户正在探索「AI 工具」星域(提示词工程、RAG、微调、开发框架),结合这个语境回答。',
dim5: '用户正在探索「AI 趋势」星域(最新产品、前沿论文、行业预测),结合这个语境回答。'
}
dim3: `你是"宇之然AI维度"的AI助手,专门解答关于当前 AI 行业格局的问题。
请用通俗易懂的语言解释当前 AI 领域的最新动态,如:
- 各大模型公司(OpenAI、Google、DeepSeek、Anthropic、Meta)的竞争格局
- 开源 vs 闭源模型的生态对比
- 具身智能与世界模型的最新进展
- AI 应用在各行业的落地情况
- Agent 智能体的发展现状
回答要简洁有趣,适合普通用户理解。`,
dim4: `你是"宇之然AI维度"的AI助手,专门解答关于 AI 学习和工具使用的问题。
请用通俗易懂的语言解释 AI 学习路线和工具使用方法,如:
- 提示词工程(Prompt Engineering)入门
- 检索增强生成(RAG)的原理与实践
- 模型微调(Fine-tuning)基础
- 主流 AI 开发框架(LangChain、Dify 等)
- 常用 AI 工具推荐与使用技巧
回答要简洁有趣,适合普通用户理解。`,
dim5: `你是"宇之然AI维度"的AI助手,专门解答关于 AI 趋势和前沿动态的问题。
请用通俗易懂的语言介绍 AI 领域的最新趋势,如:
- 最新 AI 产品发布动态
- 前沿论文与研究方向
- 行业趋势分析与预测
- AI 政策法规与伦理讨论
回答要简洁有趣,适合普通用户理解。`,
general: `你是"宇之然AI维度"的AI助手,致力于帮助用户系统化理解人工智能。
你可以回答关于 AI 的任何问题,包括但不限于:
- AI 历史与起源
- 核心技术原理
- 当前行业格局
- 学习路线与工具使用
- 最新趋势与前沿动态
你的回答要简洁、通俗易懂,适合普通用户理解。如果用户问的是某个具体维度的知识,可以引导用户去对应的维度模块深入学习。`
}
return prompts[dimension] || prompts.general
function getSystemPrompt(dimension) {
const hint = DIM_HINTS[dimension] || ''
return `${PERSONA}\n\n${hint}`
}
module.exports = router
+340 -225
View File
@@ -1,225 +1,340 @@
const express = require('express')
const router = express.Router()
const { Knowledge } = require('../models')
const ApiResponse = require('../utils/response')
const { userAuthMiddleware } = require('../middleware/auth')
const logger = require('../utils/logger')
/**
* 搜索知识
* GET /api/knowledge/search?q=xxx&page=1&limit=20
*/
router.get('/search', async (req, res) => {
try {
const { q, page = 1, limit = 20 } = req.query
if (!q || q.trim().length === 0) {
return ApiResponse.error(res, '请输入搜索关键词', 400)
}
const query = {
status: 'approved',
$or: [
{ title: { $regex: q, $options: 'i' } },
{ content: { $regex: q, $options: 'i' } },
{ tags: { $regex: q, $options: 'i' } }
]
}
const total = await Knowledge.countDocuments(query)
const result = await Knowledge.find(query)
.sort({ viewCount: -1, createdAt: -1 })
.skip((parseInt(page) - 1) * parseInt(limit))
.limit(parseInt(limit))
.lean()
return ApiResponse.paginated(res, result, {
page: parseInt(page),
limit: parseInt(limit),
total
})
} catch (error) {
logger.error('搜索知识失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
/**
* 获取知识列表
* GET /api/knowledge
*/
router.get('/', async (req, res) => {
try {
const { page = 1, limit = 20, dim, category, status = 'approved' } = req.query
const query = { status }
if (dim) query.dim = parseInt(dim)
if (category) query.category = category
const total = await Knowledge.countDocuments(query)
const knowledge = await Knowledge.find(query)
.sort({ sortOrder: 1, createdAt: -1 })
.skip((parseInt(page) - 1) * parseInt(limit))
.limit(parseInt(limit))
.lean()
return ApiResponse.paginated(res, knowledge, {
page: parseInt(page),
limit: parseInt(limit),
total
})
} catch (error) {
logger.error('获取知识列表失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
/**
* 获取用户收藏的知识列表
* GET /api/knowledge/collected
*/
router.get('/collected', userAuthMiddleware, async (req, res) => {
try {
const user = req.user
// 查找用户收藏的知识
const collectedKnowledge = await Knowledge.find({ _id: { $in: user.collectedKnowledge } })
.lean()
// 格式化返回数据
const formattedKnowledge = collectedKnowledge.map(item => ({
id: item._id,
dimension: item.dim,
title: item.title,
description: item.content || item.description || '',
knowledgeId: item._id
}))
return ApiResponse.success(res, formattedKnowledge)
} catch (error) {
logger.error('获取收藏列表失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
/**
* 删除收藏
* DELETE /api/knowledge/collect/:id
*/
router.delete('/collect/:id', userAuthMiddleware, async (req, res) => {
try {
const knowledgeId = req.params.id
const user = req.user
// 检查是否已经收藏
if (!user.collectedKnowledge.includes(knowledgeId)) {
return ApiResponse.error(res, '未收藏该知识', 400)
}
// 从用户的 collectedKnowledge 数组中移除
user.collectedKnowledge = user.collectedKnowledge.filter(id => id.toString() !== knowledgeId.toString())
await user.save()
// 减少收藏计数
const knowledge = await Knowledge.findById(knowledgeId)
if (knowledge) {
knowledge.collectionCount = Math.max(0, knowledge.collectionCount - 1)
await knowledge.save()
}
return ApiResponse.success(res, {}, '删除收藏成功')
} catch (error) {
logger.error('删除收藏失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
/**
* 获取知识详情
* GET /api/knowledge/:id
*/
router.get('/:id', async (req, res) => {
try {
const knowledge = await Knowledge.findById(req.params.id)
if (!knowledge) {
return ApiResponse.notFound(res, '知识不存在')
}
// 增加浏览量
knowledge.viewCount += 1
await knowledge.save()
return ApiResponse.success(res, knowledge)
} catch (error) {
logger.error('获取知识详情失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
/**
* 收藏知识
* POST /api/knowledge/:id/collect
*/
router.post('/:id/collect', userAuthMiddleware, async (req, res) => {
try {
const knowledge = await Knowledge.findById(req.params.id)
if (!knowledge) {
return ApiResponse.notFound(res, '知识不存在')
}
const user = req.user
// 检查是否已经收藏
if (user.collectedKnowledge.includes(knowledge._id)) {
return ApiResponse.error(res, '已经收藏过了', 400)
}
// 将知识ID加入用户的 collectedKnowledge 数组
user.collectedKnowledge.push(knowledge._id)
await user.save()
// 增加收藏计数
knowledge.collectionCount += 1
await knowledge.save()
return ApiResponse.success(res, { collectionCount: knowledge.collectionCount }, '收藏成功')
} catch (error) {
logger.error('收藏失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
/**
* 获取问答
* GET /api/knowledge/:dim/questions
*/
router.get('/:dim/questions', async (req, res) => {
try {
const { dim } = req.params
const { limit = 10 } = req.query
const questions = await Knowledge.find({
dim: parseInt(dim),
category: 'question',
status: 'approved'
})
.select('question options')
.limit(parseInt(limit))
.lean()
return ApiResponse.success(res, questions)
} catch (error) {
logger.error('获取问答失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
module.exports = router
const express = require('express')
const router = express.Router()
const mongoose = require('mongoose')
const { Knowledge, KnowledgeLit } = require('../models')
const ApiResponse = require('../utils/response')
const { userAuthMiddleware, optionalAuthMiddleware } = require('../middleware/auth')
const logger = require('../utils/logger')
const isValidId = (id) => mongoose.Types.ObjectId.isValid(id)
/**
* 计算连续点亮天数(streak)
*/
function calcStreak(dates) {
if (!dates.length) return 0
const days = new Set(dates.map(d => new Date(d).toDateString()))
const today = new Date()
const yesterday = new Date(Date.now() - 86400000)
if (!days.has(today.toDateString()) && !days.has(yesterday.toDateString())) return 0
let streak = 0
const cursor = new Date()
if (!days.has(cursor.toDateString())) cursor.setTime(Date.now() - 86400000)
while (days.has(cursor.toDateString())) {
streak++
cursor.setTime(cursor.getTime() - 86400000)
}
return streak
}
/**
* 搜索知识
* GET /api/knowledge/search?q=xxx&page=1&limit=20
*/
router.get('/search', async (req, res) => {
try {
const { q, page = 1, limit = 20 } = req.query
if (!q || q.trim().length === 0) {
return ApiResponse.error(res, '请输入搜索关键词', 400)
}
const query = {
status: 'approved',
$or: [
{ title: { $regex: q, $options: 'i' } },
{ content: { $regex: q, $options: 'i' } },
{ tags: { $regex: q, $options: 'i' } }
]
}
const total = await Knowledge.countDocuments(query)
const result = await Knowledge.find(query)
.sort({ viewCount: -1, createdAt: -1 })
.skip((parseInt(page) - 1) * parseInt(limit))
.limit(parseInt(limit))
.lean()
return ApiResponse.paginated(res, result, {
page: parseInt(page),
limit: parseInt(limit),
total
})
} catch (error) {
logger.error('搜索知识失败:', error)
return ApiResponse.serverError(res, '搜索失败')
}
})
/**
* 星域统计(公共计数 + 登录用户的点亮进度)
* GET /api/knowledge/stats
*/
router.get('/stats', optionalAuthMiddleware, async (req, res) => {
try {
const [total, dimAgg] = await Promise.all([
Knowledge.countDocuments({ status: 'approved' }),
Knowledge.aggregate([
{ $match: { status: 'approved' } },
{ $group: { _id: '$dim', count: { $sum: 1 } } }
])
])
const byDim = {}
for (let i = 1; i <= 5; i++) byDim[i] = 0
dimAgg.forEach(r => { byDim[r._id] = r.count })
let user = null
if (req.user) {
const openid = req.openid
const lits = await KnowledgeLit.find({ openid }).lean()
const litByDim = {}
for (let i = 1; i <= 5; i++) litByDim[i] = 0
lits.forEach(l => {
const d = l.dim || 1
litByDim[d] = (litByDim[d] || 0) + 1
})
user = {
totalLit: lits.length,
litByDim,
streak: calcStreak(lits.map(l => l.litAt)),
litIds: lits.map(l => String(l.knowledgeId))
}
}
return ApiResponse.success(res, { total, byDim, user })
} catch (error) {
logger.error('获取星域统计失败:', error)
return ApiResponse.serverError(res, '获取统计失败')
}
})
/**
* 获取用户收藏的知识列表
* GET /api/knowledge/collected
*/
router.get('/collected', userAuthMiddleware, async (req, res) => {
try {
const user = req.user
const collectedKnowledge = await Knowledge.find({ _id: { $in: user.collectedKnowledge } })
.lean()
const formattedKnowledge = collectedKnowledge.map(item => ({
id: item._id,
dimension: item.dim,
title: item.title,
description: item.content || item.description || '',
knowledgeId: item._id
}))
return ApiResponse.success(res, formattedKnowledge)
} catch (error) {
logger.error('获取收藏列表失败:', error)
return ApiResponse.serverError(res, '获取收藏失败')
}
})
/**
* 删除收藏
* DELETE /api/knowledge/collect/:id
*/
router.delete('/collect/:id', userAuthMiddleware, async (req, res) => {
try {
const knowledgeId = req.params.id
const user = req.user
if (!user.collectedKnowledge.some(id => id.toString() === knowledgeId)) {
return ApiResponse.error(res, '未收藏该知识', 400)
}
user.collectedKnowledge = user.collectedKnowledge.filter(id => id.toString() !== knowledgeId)
await user.save()
await Knowledge.updateOne(
{ _id: knowledgeId },
{ $inc: { collectionCount: -1 } }
)
return ApiResponse.success(res, {}, '删除收藏成功')
} catch (error) {
logger.error('删除收藏失败:', error)
return ApiResponse.serverError(res, '操作失败')
}
})
/**
* 获取知识列表
* GET /api/knowledge
*/
router.get('/', async (req, res) => {
try {
const { page = 1, limit = 20, dim, category, status = 'approved' } = req.query
const query = { status }
if (dim) query.dim = parseInt(dim)
if (category) query.category = category
const total = await Knowledge.countDocuments(query)
const knowledge = await Knowledge.find(query)
.sort({ sortOrder: 1, createdAt: -1 })
.skip((parseInt(page) - 1) * parseInt(limit))
.limit(parseInt(limit))
.lean()
return ApiResponse.paginated(res, knowledge, {
page: parseInt(page),
limit: parseInt(limit),
total
})
} catch (error) {
logger.error('获取知识列表失败:', error)
return ApiResponse.serverError(res, '获取列表失败')
}
})
/**
* 获取知识详情(不泄露答案:options 去掉 isCorrect,answer 字段不返回)
* GET /api/knowledge/:id
*/
router.get('/:id', async (req, res) => {
try {
if (!isValidId(req.params.id)) {
return ApiResponse.notFound(res, '知识不存在')
}
const knowledge = await Knowledge.findById(req.params.id).lean()
if (!knowledge) {
return ApiResponse.notFound(res, '知识不存在')
}
// 浏览量异步自增,不阻塞响应
Knowledge.updateOne({ _id: knowledge._id }, { $inc: { viewCount: 1 } }).catch(() => {})
if (Array.isArray(knowledge.options)) {
knowledge.options = knowledge.options.map(o => ({ text: o.text }))
}
delete knowledge.answer
return ApiResponse.success(res, knowledge)
} catch (error) {
logger.error('获取知识详情失败:', error)
return ApiResponse.serverError(res, '获取详情失败')
}
})
/**
* 收藏知识
* POST /api/knowledge/:id/collect
*/
router.post('/:id/collect', userAuthMiddleware, async (req, res) => {
try {
const knowledge = await Knowledge.findById(req.params.id)
if (!knowledge) {
return ApiResponse.notFound(res, '知识不存在')
}
const user = req.user
if (user.collectedKnowledge.some(id => id.toString() === knowledge._id.toString())) {
return ApiResponse.error(res, '已经收藏过了', 400)
}
user.collectedKnowledge.push(knowledge._id)
await user.save()
knowledge.collectionCount += 1
await knowledge.save()
return ApiResponse.success(res, { collectionCount: knowledge.collectionCount }, '收藏成功')
} catch (error) {
logger.error('收藏失败:', error)
return ApiResponse.serverError(res, '操作失败')
}
})
/**
* 检测题作答(服务端判题;答对且登录则点亮该星球)
* POST /api/knowledge/:id/quiz { optionIndex }
*/
router.post('/:id/quiz', optionalAuthMiddleware, async (req, res) => {
try {
if (!isValidId(req.params.id)) {
return ApiResponse.notFound(res, '知识不存在')
}
const knowledge = await Knowledge.findById(req.params.id).lean()
if (!knowledge) {
return ApiResponse.notFound(res, '知识不存在')
}
if (!Array.isArray(knowledge.options) || knowledge.options.length < 2) {
return ApiResponse.error(res, '该知识暂无检测题', 400)
}
const { optionIndex } = req.body
const idx = parseInt(optionIndex)
if (Number.isNaN(idx) || idx < 0 || idx >= knowledge.options.length) {
return ApiResponse.error(res, '无效的选项', 400)
}
const correctIndex = knowledge.options.findIndex(o => o.isCorrect)
const correct = idx === correctIndex
let lit = false
let totalLit = 0
let streak = 0
if (correct && req.user) {
const openid = req.openid
await KnowledgeLit.findOneAndUpdate(
{ openid, knowledgeId: knowledge._id },
{ openid, knowledgeId: knowledge._id, dim: knowledge.dim, quizPassed: true, litAt: new Date() },
{ upsert: true }
)
lit = true
const lits = await KnowledgeLit.find({ openid }).lean()
totalLit = lits.length
streak = calcStreak(lits.map(l => l.litAt))
}
return ApiResponse.success(res, {
correct,
correctIndex,
answer: knowledge.answer || '',
lit,
totalLit,
streak
})
} catch (error) {
logger.error('检测题作答失败:', error)
return ApiResponse.serverError(res, '操作失败')
}
})
/**
* 获取某维度的问答列表
* GET /api/knowledge/:dim/questions
*/
router.get('/:dim/questions', async (req, res) => {
try {
const { dim } = req.params
const { limit = 10 } = req.query
const questions = await Knowledge.find({
dim: parseInt(dim),
category: 'question',
status: 'approved'
})
.select('question options')
.limit(parseInt(limit))
.lean()
return ApiResponse.success(res, questions)
} catch (error) {
logger.error('获取问答失败:', error)
return ApiResponse.serverError(res, '获取问答失败')
}
})
module.exports = router
+10 -9
View File
@@ -1,13 +1,14 @@
import express from 'express'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const express = require('express')
const router = express.Router()
const SITE = process.env.SITE_URL || ''
const origin = (req) => (SITE ? SITE.replace(/\/$/, '') : `${req.protocol}://${req.get('host')}`)
// 反代场景下优先取 x-forwarded 头,保证 sitemap/robots 里的站点地址正确
function origin(req) {
const site = process.env.SITE_URL || ''
if (site) return site.replace(/\/$/, '')
const proto = (req.headers['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim()
const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost'
return proto + '://' + host
}
const DESC =
'宇之然AI维度:从五个维度(AI起源、发展、当前格局、学习工具、趋势跟踪)系统理解人工智能的知识探索社区。每日更新AI行业趋势与热点,支持问答、收藏与学习进度跟踪。'
@@ -99,4 +100,4 @@ router.get('/about', (req, res) => {
</body></html>`)
})
export default router
module.exports = router