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:
+102
-131
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user