021f56a83f
后端:
- 新增知识搜索 API GET /api/knowledge/search
- 成就 API 改造为 AI 主题(知识探索/收藏/AI问答/社交)
前端新增 7 个页面:
- favorites (我的收藏)
- achievements (成就)
- leaderboard (排行榜)
- search (搜索)
- progress (学习进度)
- feedback (意见反馈)
- settings (设置)
- news (AI资讯详情)
完善:
- 个人中心导航全部接真实页面,清除所有'开发中'stub
- index 搜索按钮 → search 页
- trend 卡片点击 → news 详情页
- api.js 新增 feedbackApi + knowledgeApi.search
- userApi.achievements 端点
构建验证: ✅ DONE Build complete (16 pages, 16 bundles)
425 lines
15 KiB
JavaScript
Executable File
425 lines
15 KiB
JavaScript
Executable File
const express = require('express')
|
|
const router = express.Router()
|
|
const { User, Order, Gallery } = require('../models')
|
|
const ApiResponse = require('../utils/response')
|
|
const { userAuthMiddleware } = require('../middleware/auth')
|
|
const logger = require('../utils/logger')
|
|
|
|
// 应用用户认证中间件到所有路由
|
|
router.use(userAuthMiddleware)
|
|
|
|
/**
|
|
* 获取用户进度
|
|
* GET /api/user/progress
|
|
*/
|
|
router.get('/progress', async (req, res) => {
|
|
try {
|
|
const user = req.user
|
|
|
|
if (!user) {
|
|
// 新用户,返回默认进度
|
|
return ApiResponse.success(res, {
|
|
isNewUser: true,
|
|
data: {
|
|
unlockedDims: [1],
|
|
dim1Score: 0,
|
|
dim2Score: 0,
|
|
dim3Score: 0,
|
|
dim4Score: 0,
|
|
dim5Score: 0,
|
|
totalScore: 0,
|
|
ownedSkins: [],
|
|
isAdFree: false,
|
|
isSubscriber: false,
|
|
subscribeExpiry: null,
|
|
totalPlayTime: 0
|
|
}
|
|
})
|
|
}
|
|
|
|
return ApiResponse.success(res, {
|
|
isNewUser: false,
|
|
data: user
|
|
})
|
|
|
|
} catch (error) {
|
|
logger.error('获取进度失败:', error)
|
|
return ApiResponse.serverError(res, error.message)
|
|
}
|
|
})
|
|
|
|
/**
|
|
* 保存用户进度
|
|
* POST /api/user/progress
|
|
*/
|
|
router.post('/progress', async (req, res) => {
|
|
try {
|
|
const user = req.user
|
|
const openid = user.openid
|
|
|
|
const {
|
|
dim,
|
|
data,
|
|
dim1Score,
|
|
dim2Score,
|
|
dim3Score,
|
|
dim4Score,
|
|
dim5Score,
|
|
unlockedDims,
|
|
ownedSkins,
|
|
isAdFree,
|
|
isSubscriber,
|
|
subscribeExpiry,
|
|
totalPlayTime
|
|
} = req.body
|
|
|
|
// 查找或创建用户(理论上已通过中间件找到)
|
|
let dbUser = user
|
|
|
|
// 新版接口:按维度更新
|
|
if (dim && data) {
|
|
const dimData = dbUser.exploreData[dim] || {}
|
|
let updatedDim = { ...dimData }
|
|
|
|
if (dim === 'dim1') {
|
|
updatedDim.completed = data.completed || dimData.completed || false
|
|
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
|
updatedDim.collectedEggs = (dimData.collectedEggs || 0) + (data.newEggs || 0)
|
|
updatedDim.totalLength = Math.max(data.totalLength || 0, dimData.totalLength || 0)
|
|
} else if (dim === 'dim2') {
|
|
updatedDim.completed = data.completed || dimData.completed || false
|
|
updatedDim.bestArea = Math.max(data.bestArea || 0, dimData.bestArea || 0)
|
|
updatedDim.createdShapes = (dimData.createdShapes || 0) + (data.newShapes || 0)
|
|
} else if (dim === 'dim3') {
|
|
updatedDim.completed = data.completed || dimData.completed || false
|
|
updatedDim.exploredFaces = Math.max(data.exploredFaces || 0, dimData.exploredFaces || 0)
|
|
updatedDim.rotationTime = (dimData.rotationTime || 0) + (data.rotationTime || 0)
|
|
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
|
} else if (dim === 'dim4') {
|
|
updatedDim.completed = data.completed || dimData.completed || false
|
|
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
|
updatedDim.exploredEvents = (dimData.exploredEvents || 0) + (data.newEvents || 0)
|
|
} else if (dim === 'dim5') {
|
|
updatedDim.completed = data.completed || dimData.completed || false
|
|
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
|
updatedDim.exploredThoughts = (dimData.exploredThoughts || 0) + (data.newThoughts || 0)
|
|
}
|
|
|
|
dbUser.exploreData[dim] = updatedDim
|
|
|
|
// 同步更新已解锁维度
|
|
if (data.completed && !dbUser.unlockedDims.includes(parseInt(dim.replace('dim', '')) + 1)) {
|
|
const dimNum = parseInt(dim.replace('dim', ''))
|
|
if (!dbUser.unlockedDims.includes(dimNum + 1)) {
|
|
dbUser.unlockedDims.push(dimNum + 1)
|
|
}
|
|
}
|
|
|
|
if (totalPlayTime !== null && totalPlayTime !== undefined) {
|
|
dbUser.totalPlayTime += totalPlayTime
|
|
}
|
|
}
|
|
|
|
// 兼容旧版接口:直接传各维度分数
|
|
if (dim1Score !== null && dim1Score !== undefined) {
|
|
dbUser.exploreData.dim1.bestScore = Math.max(dim1Score, dbUser.exploreData.dim1?.bestScore || 0)
|
|
}
|
|
if (dim2Score !== null && dim2Score !== undefined) {
|
|
dbUser.exploreData.dim2.bestArea = Math.max(dim2Score, dbUser.exploreData.dim2?.bestArea || 0)
|
|
}
|
|
if (dim3Score !== null && dim3Score !== undefined) {
|
|
dbUser.exploreData.dim3.bestScore = Math.max(dim3Score, dbUser.exploreData.dim3?.bestScore || 0)
|
|
}
|
|
if (dim4Score !== null && dim4Score !== undefined) {
|
|
dbUser.exploreData.dim4 = dbUser.exploreData.dim4 || {}
|
|
dbUser.exploreData.dim4.bestScore = Math.max(dim4Score, dbUser.exploreData.dim4?.bestScore || 0)
|
|
}
|
|
if (dim5Score !== null && dim5Score !== undefined) {
|
|
dbUser.exploreData.dim5 = dbUser.exploreData.dim5 || {}
|
|
dbUser.exploreData.dim5.bestScore = Math.max(dim5Score, dbUser.exploreData.dim5?.bestScore || 0)
|
|
}
|
|
|
|
// 更新其他字段
|
|
if (ownedSkins !== null && ownedSkins !== undefined) dbUser.ownedSkins = ownedSkins
|
|
if (isAdFree !== null && isAdFree !== undefined) dbUser.isAdFree = isAdFree
|
|
if (isSubscriber !== null && isSubscriber !== undefined) dbUser.isSubscriber = isSubscriber
|
|
if (subscribeExpiry !== null && subscribeExpiry !== undefined) dbUser.subscribeExpiry = subscribeExpiry
|
|
|
|
await dbUser.save()
|
|
|
|
// 计算总分
|
|
const totalScore = dbUser.calculateTotalScore()
|
|
|
|
return ApiResponse.success(res, {
|
|
success: true,
|
|
totalScore,
|
|
exploreData: user.exploreData
|
|
})
|
|
|
|
} catch (error) {
|
|
logger.error('保存进度失败:', error)
|
|
return ApiResponse.serverError(res, error.message)
|
|
}
|
|
})
|
|
|
|
/**
|
|
* 获取排行榜
|
|
* GET /api/user/leaderboard
|
|
*/
|
|
router.get('/leaderboard', async (req, res) => {
|
|
try {
|
|
const { limit = 50, dim } = req.query
|
|
|
|
const users = await User.getLeaderboard(dim ? parseInt(dim) : null, parseInt(limit))
|
|
|
|
const data = users.map((u, i) => ({
|
|
rank: i + 1,
|
|
openid: u.openid,
|
|
nickname: u.nickName || '星辰旅者',
|
|
avatar: u.avatarUrl || '',
|
|
totalScore: u.totalScore || 0,
|
|
dim1Score: u.dim1Score || 0,
|
|
dim2Score: u.dim2Score || 0,
|
|
dim3Score: u.dim3Score || 0,
|
|
dim4Score: u.dim4Score || 0,
|
|
dim5Score: u.dim5Score || 0,
|
|
unlockedDims: u.unlockedDims || [1]
|
|
}))
|
|
|
|
return ApiResponse.success(res, data)
|
|
|
|
} catch (error) {
|
|
logger.error('获取排行榜失败:', error)
|
|
return ApiResponse.serverError(res, error.message)
|
|
}
|
|
})
|
|
|
|
/**
|
|
* 获取用户成就
|
|
* GET /api/user/achievements
|
|
*/
|
|
router.get('/achievements', async (req, res) => {
|
|
try {
|
|
const user = req.user
|
|
|
|
// 生成成就数据(AI 维度主题)
|
|
const achievements = [
|
|
{
|
|
name: '知识探索',
|
|
achievements: [
|
|
{
|
|
id: 'dim1_read',
|
|
name: 'AI起源探索者',
|
|
description: '完成 AI 起源维度的知识阅读',
|
|
icon: '📜',
|
|
unlocked: user.exploreData.dim1?.completed || false,
|
|
date: user.exploreData.dim1?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
|
},
|
|
{
|
|
id: 'dim2_read',
|
|
name: 'AI发展探索者',
|
|
description: '完成 AI 发展维度的知识阅读',
|
|
icon: '🏛',
|
|
unlocked: user.exploreData.dim2?.completed || false,
|
|
date: user.exploreData.dim2?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
|
},
|
|
{
|
|
id: 'dim3_read',
|
|
name: 'AI当前探索者',
|
|
description: '完成 AI 当前格局维度的知识阅读',
|
|
icon: '♟',
|
|
unlocked: user.exploreData.dim3?.completed || false,
|
|
date: user.exploreData.dim3?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
|
},
|
|
{
|
|
id: 'dim4_read',
|
|
name: 'AI学习探索者',
|
|
description: '完成 AI 学习与工具维度的知识阅读',
|
|
icon: '🧠',
|
|
unlocked: user.exploreData.dim4?.completed || false,
|
|
date: user.exploreData.dim4?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
|
},
|
|
{
|
|
id: 'dim5_read',
|
|
name: 'AI趋势探索者',
|
|
description: '完成 AI 趋势追踪维度的知识阅读',
|
|
icon: '⚡',
|
|
unlocked: user.exploreData.dim5?.completed || false,
|
|
date: user.exploreData.dim5?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
|
}
|
|
]
|
|
},
|
|
{
|
|
name: '收藏成就',
|
|
achievements: [
|
|
{
|
|
id: 'first_collection',
|
|
name: '初次收藏',
|
|
description: '收藏你的第一个 AI 知识',
|
|
icon: '❤️',
|
|
unlocked: user.collectedKnowledge?.length > 0,
|
|
date: user.collectedKnowledge?.length > 0 ? user.updatedAt.toISOString().split('T')[0] : null
|
|
},
|
|
{
|
|
id: 'five_collections',
|
|
name: '收藏爱好者',
|
|
description: '收藏5个 AI 知识',
|
|
icon: '🧡',
|
|
unlocked: user.collectedKnowledge?.length >= 5,
|
|
date: user.collectedKnowledge?.length >= 5 ? user.updatedAt.toISOString().split('T')[0] : null
|
|
},
|
|
{
|
|
id: 'ten_collections',
|
|
name: '收藏大师',
|
|
description: '收藏10个 AI 知识',
|
|
icon: '💛',
|
|
unlocked: user.collectedKnowledge?.length >= 10,
|
|
date: user.collectedKnowledge?.length >= 10 ? user.updatedAt.toISOString().split('T')[0] : null
|
|
}
|
|
]
|
|
},
|
|
{
|
|
name: 'AI 问答',
|
|
achievements: [
|
|
{
|
|
id: 'first_ask',
|
|
name: '初次提问',
|
|
description: '向 AI 助手提出第一个问题',
|
|
icon: '🤖',
|
|
unlocked: user.achievements?.includes('first_ask') || false,
|
|
date: user.achievements?.includes('first_ask') ? user.updatedAt.toISOString().split('T')[0] : null
|
|
},
|
|
{
|
|
id: 'ten_asks',
|
|
name: '勤学好问',
|
|
description: '向 AI 助手提出 10 个问题',
|
|
icon: '💡',
|
|
unlocked: user.achievements?.includes('ten_asks') || false,
|
|
date: user.achievements?.includes('ten_asks') ? user.updatedAt.toISOString().split('T')[0] : null
|
|
},
|
|
{
|
|
id: 'fifty_asks',
|
|
name: 'AI 达人',
|
|
description: '向 AI 助手提出 50 个问题',
|
|
icon: '🧠',
|
|
unlocked: user.achievements?.includes('fifty_asks') || false,
|
|
date: user.achievements?.includes('fifty_asks') ? user.updatedAt.toISOString().split('T')[0] : null
|
|
}
|
|
]
|
|
},
|
|
{
|
|
name: '社交成就',
|
|
achievements: [
|
|
{
|
|
id: 'first_share',
|
|
name: '初次分享',
|
|
description: '分享你的第一个知识给朋友',
|
|
icon: '📤',
|
|
unlocked: user.achievements?.includes('first_share') || false,
|
|
date: user.achievements?.includes('first_share') ? user.updatedAt.toISOString().split('T')[0] : null
|
|
},
|
|
{
|
|
id: 'five_shares',
|
|
name: '分享达人',
|
|
description: '分享 5 次知识给朋友',
|
|
icon: '📣',
|
|
unlocked: user.achievements?.includes('five_shares') || false,
|
|
date: user.achievements?.includes('five_shares') ? user.updatedAt.toISOString().split('T')[0] : null
|
|
}
|
|
]
|
|
}
|
|
]
|
|
|
|
return ApiResponse.success(res, achievements)
|
|
|
|
} catch (error) {
|
|
logger.error('获取成就失败:', error)
|
|
return ApiResponse.serverError(res, error.message)
|
|
}
|
|
})
|
|
|
|
/**
|
|
* 获取用户统计数据
|
|
* GET /api/user/stats
|
|
*/
|
|
router.get('/stats', async (req, res) => {
|
|
try {
|
|
const user = req.user
|
|
const { Gallery } = require('../models')
|
|
|
|
// 获取用户作品数
|
|
const worksCount = await Gallery.countDocuments({ userId: user._id })
|
|
|
|
// 获取等级信息
|
|
const levelInfo = user.getLevelInfo()
|
|
|
|
// 计算探索时长(分钟转小时,保留1位小数)
|
|
const playTimeHours = Math.round((user.totalPlayTime || 0) / 60 * 10) / 10
|
|
|
|
return ApiResponse.success(res, {
|
|
level: levelInfo.level,
|
|
levelName: levelInfo.name,
|
|
levelPoints: levelInfo.points,
|
|
levelProgress: levelInfo.progress,
|
|
totalProgress: levelInfo.totalProgress,
|
|
nextLevelPoints: levelInfo.nextLevelPoints,
|
|
totalPlayTime: playTimeHours,
|
|
totalScore: user.totalScore || 0,
|
|
worksCount: worksCount,
|
|
collectionCount: user.collectedKnowledge?.length || 0,
|
|
achievementCount: user.achievements?.length || 0,
|
|
unlockedDims: user.unlockedDims || [1]
|
|
})
|
|
|
|
} catch (error) {
|
|
logger.error('获取用户统计失败:', error)
|
|
return ApiResponse.serverError(res, error.message)
|
|
}
|
|
})
|
|
|
|
/**
|
|
* 获取用户作品列表
|
|
* GET /api/user/works
|
|
*/
|
|
router.get('/works', async (req, res) => {
|
|
try {
|
|
const user = req.user
|
|
const { page = 1, limit = 20 } = req.query
|
|
const { Gallery } = require('../models')
|
|
|
|
const query = { userId: user._id }
|
|
|
|
const total = await Gallery.countDocuments(query)
|
|
const works = await Gallery.find(query)
|
|
.sort({ createdAt: -1 })
|
|
.skip((parseInt(page) - 1) * parseInt(limit))
|
|
.limit(parseInt(limit))
|
|
.lean()
|
|
|
|
// 格式化作品数据
|
|
const formattedWorks = works.map(work => ({
|
|
id: work._id,
|
|
title: work.title,
|
|
description: work.description,
|
|
imageUrl: work.imageUrl,
|
|
dimension: work.dimension,
|
|
status: work.status,
|
|
likes: work.likes || 0,
|
|
views: work.views || 0,
|
|
createdAt: work.createdAt
|
|
}))
|
|
|
|
return ApiResponse.paginated(res, formattedWorks, {
|
|
page: parseInt(page),
|
|
limit: parseInt(limit),
|
|
total
|
|
})
|
|
|
|
} catch (error) {
|
|
logger.error('获取用户作品失败:', error)
|
|
return ApiResponse.serverError(res, error.message)
|
|
}
|
|
})
|
|
|
|
module.exports = router
|