🎉 feat: initialize 宇之然AI维度 project
- uni-app Vue3 frontend with dark glassmorphism theme - 5 AI dimensions: Origin / Development / Current / Learning / Trend - AI Chat system prompt updated from geometry to AI - 23 AI knowledge articles initialized in DB - Trend API + cron job for daily news - Product pricing (Pro ¥19.9, VIP ¥39.9) - Pinia stores + API utilities - AGENTS.md documentation
This commit is contained in:
Executable
+1345
File diff suppressed because it is too large
Load Diff
Executable
+240
@@ -0,0 +1,240 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { AIChatQuota, AIModel } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 获取用户AI问答配额
|
||||
* GET /api/ai-chat/quota
|
||||
*/
|
||||
router.get('/quota', 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) {
|
||||
// 创建新的配额记录,默认5次免费
|
||||
quota = new AIChatQuota({ openid })
|
||||
await quota.save()
|
||||
}
|
||||
|
||||
// 检查是否需要重置每日使用次数
|
||||
quota.resetDailyUsed()
|
||||
await quota.save()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
freeQuota: quota.freeQuota,
|
||||
sharedQuota: quota.sharedQuota,
|
||||
usedQuota: quota.usedQuota,
|
||||
remainingQuota: quota.getRemainingQuota(),
|
||||
dailyUsed: quota.dailyUsed
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取AI问答配额失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* AI问答接口
|
||||
* POST /api/ai-chat/ask
|
||||
*/
|
||||
router.post('/ask', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const openid = 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 (!quota.hasQuota()) {
|
||||
return ApiResponse.error(res, '问答次数已用完,分享好友可获得更多次数', 403)
|
||||
}
|
||||
|
||||
// 获取当前使用的AI模型配置
|
||||
const aiModel = await AIModel.getCurrentModel()
|
||||
|
||||
let apiUrl, apiKey, modelId, modelConfig
|
||||
|
||||
if (aiModel) {
|
||||
// 使用数据库配置的模型
|
||||
apiUrl = aiModel.apiUrl
|
||||
apiKey = aiModel.apiKey
|
||||
modelId = aiModel.modelId
|
||||
modelConfig = aiModel.config
|
||||
} else {
|
||||
// 使用环境变量配置的模型
|
||||
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'
|
||||
modelConfig = { temperature: 0.7, maxTokens: 800, topP: 1 }
|
||||
}
|
||||
|
||||
// 检查API配置
|
||||
if (!apiKey) {
|
||||
logger.error('AI API Key未配置')
|
||||
return ApiResponse.error(res, 'AI服务暂时不可用', 500)
|
||||
}
|
||||
|
||||
// 构建系统提示词
|
||||
const systemPrompt = getSystemPromptByDimension(dimension)
|
||||
|
||||
// 调用AI API
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: question }
|
||||
],
|
||||
temperature: modelConfig.temperature || 0.7,
|
||||
max_tokens: modelConfig.maxTokens || 800,
|
||||
top_p: modelConfig.topP || 1
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('OpenAI API错误:', errorData)
|
||||
return ApiResponse.error(res, 'AI服务响应异常', 500)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const answer = data.choices[0].message.content
|
||||
|
||||
// 扣除一次使用次数
|
||||
await quota.useQuota()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
answer,
|
||||
remainingQuota: quota.getRemainingQuota()
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('AI问答失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 分享获得问答次数
|
||||
* 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)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 根据维度获取系统提示词
|
||||
*/
|
||||
function getSystemPromptByDimension(dimension) {
|
||||
const prompts = {
|
||||
dim1: `你是"宇之然AI维度"的AI助手,专门解答关于 AI 起源和历史的问题。
|
||||
请用通俗易懂的语言解释人工智能发展史上的重要事件、人物和里程碑,如:
|
||||
- 图灵测试(1950)与艾伦·图灵的贡献
|
||||
- 达特茅斯会议(1956)——AI 的诞生
|
||||
- 深蓝 vs 卡斯帕罗夫(1997)
|
||||
- AlphaGo 与深度学习革命(2016)
|
||||
- GPT 系列与大模型时代的开启
|
||||
回答要简洁有趣,适合普通用户理解。`,
|
||||
|
||||
dim2: `你是"宇之然AI维度"的AI助手,专门解答关于 AI 技术发展的问题。
|
||||
请用通俗易懂的语言解释人工智能核心技术演进,如:
|
||||
- 机器学习与深度学习的基本原理
|
||||
- Transformer 架构与自注意力机制
|
||||
- 大规模预训练模型(GPT、BERT等)
|
||||
- 多模态 AI、MoE 架构
|
||||
- Agent 智能体技术
|
||||
回答要简洁有趣,适合普通用户理解。`,
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
module.exports = router
|
||||
Executable
+351
@@ -0,0 +1,351 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { AIModel } = require('../models')
|
||||
const { authMiddleware, requirePermission } = require('../middleware/auth')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
// 所有路由都需要认证
|
||||
router.use(authMiddleware)
|
||||
|
||||
/**
|
||||
* 获取当前使用的模型(公开接口,用于AI问答)
|
||||
* GET /api/ai-model/current
|
||||
*/
|
||||
router.get('/current', async (req, res) => {
|
||||
try {
|
||||
const model = await AIModel.getCurrentModel()
|
||||
|
||||
if (!model) {
|
||||
// 如果没有配置模型,返回环境变量中的默认配置
|
||||
return ApiResponse.success(res, {
|
||||
name: '默认模型',
|
||||
modelId: process.env.OPENAI_MODEL || 'gpt-3.5-turbo',
|
||||
apiUrl: process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions',
|
||||
apiKey: process.env.OPENAI_API_KEY || '',
|
||||
config: {
|
||||
temperature: 0.7,
|
||||
maxTokens: 800,
|
||||
topP: 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 返回模型信息(不返回API Key)
|
||||
return ApiResponse.success(res, {
|
||||
name: model.name,
|
||||
modelId: model.modelId,
|
||||
config: model.config
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取当前模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取模型列表(管理后台)
|
||||
* GET /api/ai-model/list
|
||||
*/
|
||||
router.get('/list', requirePermission('dashboard:read'), async (req, res) => {
|
||||
try {
|
||||
const { page = 1, pageSize = 10, keyword = '' } = req.query
|
||||
|
||||
const query = keyword
|
||||
? { $or: [
|
||||
{ name: { $regex: keyword, $options: 'i' } },
|
||||
{ modelId: { $regex: keyword, $options: 'i' } }
|
||||
]}
|
||||
: {}
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
AIModel.find(query)
|
||||
.sort({ isDefault: -1, priority: 1, createdAt: -1 })
|
||||
.skip((page - 1) * pageSize)
|
||||
.limit(parseInt(pageSize)),
|
||||
AIModel.countDocuments(query)
|
||||
])
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
list,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize)
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取模型列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取所有启用的模型(下拉选择用)
|
||||
* GET /api/ai-model/active
|
||||
*/
|
||||
router.get('/active', requirePermission('dashboard:read'), async (req, res) => {
|
||||
try {
|
||||
const models = await AIModel.getActiveModels()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
list: models.map(m => ({
|
||||
_id: m._id,
|
||||
name: m.name,
|
||||
modelId: m.modelId,
|
||||
isDefault: m.isDefault
|
||||
}))
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取启用模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取模型详情
|
||||
* GET /api/ai-model/:id
|
||||
*/
|
||||
router.get('/:id', requirePermission('dashboard:read'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const model = await AIModel.findById(id)
|
||||
|
||||
if (!model) {
|
||||
return ApiResponse.notFound(res, '模型不存在')
|
||||
}
|
||||
|
||||
return ApiResponse.success(res, { model })
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取模型详情失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 创建模型
|
||||
* POST /api/ai-model
|
||||
*/
|
||||
router.post('/', requirePermission('settings:write'), async (req, res) => {
|
||||
try {
|
||||
const { name, modelId, apiUrl, apiKey, description, config, priority } = req.body
|
||||
|
||||
// 验证必填字段
|
||||
if (!name || !modelId || !apiUrl || !apiKey) {
|
||||
return ApiResponse.error(res, '请填写所有必填字段', 400)
|
||||
}
|
||||
|
||||
// 检查modelId是否已存在
|
||||
const existingModel = await AIModel.findOne({ modelId })
|
||||
if (existingModel) {
|
||||
return ApiResponse.error(res, '该模型ID已存在', 400)
|
||||
}
|
||||
|
||||
const model = new AIModel({
|
||||
name,
|
||||
modelId,
|
||||
apiUrl,
|
||||
apiKey,
|
||||
description,
|
||||
config,
|
||||
priority
|
||||
})
|
||||
|
||||
await model.save()
|
||||
|
||||
logger.info('创建AI模型:', { name, modelId, admin: req.admin.username })
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
message: '创建成功',
|
||||
model: {
|
||||
_id: model._id,
|
||||
name: model.name,
|
||||
modelId: model.modelId,
|
||||
isDefault: model.isDefault,
|
||||
isActive: model.isActive
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('创建模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 更新模型
|
||||
* PUT /api/ai-model/:id
|
||||
*/
|
||||
router.put('/:id', requirePermission('settings:write'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { name, modelId, apiUrl, apiKey, description, isActive, isDefault, config, priority } = req.body
|
||||
|
||||
const model = await AIModel.findById(id)
|
||||
|
||||
if (!model) {
|
||||
return ApiResponse.notFound(res, '模型不存在')
|
||||
}
|
||||
|
||||
// 如果修改了modelId,检查是否与其他模型冲突
|
||||
if (modelId && modelId !== model.modelId) {
|
||||
const existingModel = await AIModel.findOne({ modelId, _id: { $ne: id } })
|
||||
if (existingModel) {
|
||||
return ApiResponse.error(res, '该模型ID已存在', 400)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
if (name) model.name = name
|
||||
if (modelId) model.modelId = modelId
|
||||
if (apiUrl) model.apiUrl = apiUrl
|
||||
if (apiKey) model.apiKey = apiKey
|
||||
if (description !== undefined) model.description = description
|
||||
if (isActive !== undefined) model.isActive = isActive
|
||||
if (isDefault !== undefined) model.isDefault = isDefault
|
||||
if (config) model.config = { ...model.config, ...config }
|
||||
if (priority !== undefined) model.priority = priority
|
||||
|
||||
await model.save()
|
||||
|
||||
logger.info('更新AI模型:', { id, name: model.name, admin: req.admin.username })
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
message: '更新成功',
|
||||
model: {
|
||||
_id: model._id,
|
||||
name: model.name,
|
||||
modelId: model.modelId,
|
||||
isDefault: model.isDefault,
|
||||
isActive: model.isActive
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('更新模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 删除模型
|
||||
* DELETE /api/ai-model/:id
|
||||
*/
|
||||
router.delete('/:id', requirePermission('settings:write'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const model = await AIModel.findById(id)
|
||||
|
||||
if (!model) {
|
||||
return ApiResponse.notFound(res, '模型不存在')
|
||||
}
|
||||
|
||||
// 检查是否是唯一的默认模型
|
||||
if (model.isDefault) {
|
||||
const defaultCount = await AIModel.countDocuments({ isDefault: true })
|
||||
if (defaultCount <= 1) {
|
||||
return ApiResponse.error(res, '不能删除唯一的默认模型,请先设置其他默认模型', 400)
|
||||
}
|
||||
}
|
||||
|
||||
await AIModel.findByIdAndDelete(id)
|
||||
|
||||
logger.info('删除AI模型:', { id, name: model.name, admin: req.admin.username })
|
||||
|
||||
return ApiResponse.success(res, { message: '删除成功' })
|
||||
|
||||
} catch (error) {
|
||||
logger.error('删除模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 设置默认模型
|
||||
* POST /api/ai-model/:id/set-default
|
||||
*/
|
||||
router.post('/:id/set-default', requirePermission('settings:write'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const model = await AIModel.findById(id)
|
||||
|
||||
if (!model) {
|
||||
return ApiResponse.notFound(res, '模型不存在')
|
||||
}
|
||||
|
||||
if (!model.isActive) {
|
||||
return ApiResponse.error(res, '不能将禁用模型设为默认', 400)
|
||||
}
|
||||
|
||||
model.isDefault = true
|
||||
await model.save()
|
||||
|
||||
logger.info('设置默认AI模型:', { id, name: model.name, admin: req.admin.username })
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
message: '设置默认模型成功',
|
||||
model: {
|
||||
_id: model._id,
|
||||
name: model.name,
|
||||
modelId: model.modelId,
|
||||
isDefault: true
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('设置默认模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 初始化默认模型(从环境变量)
|
||||
* POST /api/ai-model/init-default
|
||||
*/
|
||||
router.post('/init-default', requirePermission('settings:write'), async (req, res) => {
|
||||
try {
|
||||
// 检查是否已有模型
|
||||
const existingCount = await AIModel.countDocuments()
|
||||
|
||||
if (existingCount > 0) {
|
||||
return ApiResponse.error(res, '已有模型配置,如需重新初始化请先删除现有模型', 400)
|
||||
}
|
||||
|
||||
// 从环境变量创建默认模型
|
||||
const defaultModel = new AIModel({
|
||||
name: '默认模型',
|
||||
modelId: process.env.OPENAI_MODEL || 'gpt-3.5-turbo',
|
||||
apiUrl: process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions',
|
||||
apiKey: process.env.OPENAI_API_KEY || '',
|
||||
description: '从环境变量初始化的默认模型',
|
||||
isDefault: true,
|
||||
isActive: true,
|
||||
priority: 0
|
||||
})
|
||||
|
||||
await defaultModel.save()
|
||||
|
||||
logger.info('初始化默认AI模型:', { admin: req.admin.username })
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
message: '初始化默认模型成功',
|
||||
model: {
|
||||
_id: defaultModel._id,
|
||||
name: defaultModel.name,
|
||||
modelId: defaultModel.modelId,
|
||||
isDefault: true
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('初始化默认模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
Executable
+492
@@ -0,0 +1,492 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const jwt = require('jsonwebtoken')
|
||||
const { User, Admin, AdminLog, LoginLog } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const weChatService = require('../utils/weixin')
|
||||
const logger = require('../utils/logger')
|
||||
const axios = require('axios')
|
||||
|
||||
/**
|
||||
* 获取客户端IP
|
||||
*/
|
||||
const getClientIp = (req) => {
|
||||
return req.headers['x-forwarded-for'] ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.socket.remoteAddress ||
|
||||
'0.0.0.0'
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析User-Agent
|
||||
*/
|
||||
const parseUserAgent = (userAgent) => {
|
||||
if (!userAgent) return { device: '', os: '', browser: '' }
|
||||
|
||||
const device = /Mobile|Android|iPhone|iPad|iPod/i.test(userAgent) ? 'mobile' : 'desktop'
|
||||
|
||||
let os = ''
|
||||
if (/Windows/i.test(userAgent)) os = 'Windows'
|
||||
else if (/Mac OS X/i.test(userAgent)) os = 'MacOS'
|
||||
else if (/Android/i.test(userAgent)) os = 'Android'
|
||||
else if (/iOS|iPhone|iPad|iPod/i.test(userAgent)) os = 'iOS'
|
||||
else if (/Linux/i.test(userAgent)) os = 'Linux'
|
||||
else os = 'Unknown'
|
||||
|
||||
let browser = ''
|
||||
if (/MicroMessenger/i.test(userAgent)) browser = 'WeChat'
|
||||
else if (/Chrome/i.test(userAgent)) browser = 'Chrome'
|
||||
else if (/Safari/i.test(userAgent)) browser = 'Safari'
|
||||
else if (/Firefox/i.test(userAgent)) browser = 'Firefox'
|
||||
else if (/Edge/i.test(userAgent)) browser = 'Edge'
|
||||
else browser = 'Unknown'
|
||||
|
||||
return { device, os, browser }
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录登录日志
|
||||
*/
|
||||
const recordLoginLog = async (userId, openid, type, method, source, req, success = true, failReason = '') => {
|
||||
try {
|
||||
const ip = getClientIp(req)
|
||||
const userAgent = req.headers['user-agent'] || ''
|
||||
const { device, os, browser } = parseUserAgent(userAgent)
|
||||
|
||||
await LoginLog.create({
|
||||
userId,
|
||||
openid,
|
||||
type,
|
||||
method,
|
||||
source,
|
||||
ip,
|
||||
userAgent,
|
||||
device,
|
||||
os,
|
||||
browser,
|
||||
success,
|
||||
failReason
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('记录登录日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序登录
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { code, userInfo, source = 'miniapp' } = req.body
|
||||
|
||||
// 方式1: 通过 code 登录(推荐)
|
||||
let openid = null
|
||||
if (code) {
|
||||
const sessionData = await weChatService.code2Session(code)
|
||||
openid = sessionData.openid
|
||||
}
|
||||
|
||||
// 方式2: 开发环境直接使用 openid (已废弃,仅用于本地调试)
|
||||
if (!openid && process.env.NODE_ENV === 'development' && req.body.openid) {
|
||||
openid = req.body.openid
|
||||
}
|
||||
|
||||
if (!openid) {
|
||||
return ApiResponse.error(res, '无法获取用户标识', 400)
|
||||
}
|
||||
|
||||
// 查找或创建用户
|
||||
let user = await User.findOne({ openid })
|
||||
let isNewUser = false
|
||||
|
||||
if (!user) {
|
||||
// 新用户
|
||||
user = new User({
|
||||
openid,
|
||||
nickName: userInfo?.nickName || '星辰旅行者',
|
||||
avatarUrl: userInfo?.avatarUrl || '',
|
||||
unlockedDims: [1]
|
||||
})
|
||||
isNewUser = true
|
||||
logger.info(`新用户注册: ${openid}`)
|
||||
} else {
|
||||
// 老用户,更新登录时间
|
||||
user.lastLoginAt = new Date()
|
||||
if (userInfo?.nickName) user.nickName = userInfo.nickName
|
||||
if (userInfo?.avatarUrl) user.avatarUrl = userInfo.avatarUrl
|
||||
}
|
||||
|
||||
await user.save()
|
||||
|
||||
// 记录登录日志
|
||||
await recordLoginLog(
|
||||
user._id,
|
||||
openid,
|
||||
isNewUser ? 'register' : 'login',
|
||||
'wechat_miniapp',
|
||||
source,
|
||||
req,
|
||||
true
|
||||
)
|
||||
|
||||
// 生成 JWT token(可选,小程序通常不需要)
|
||||
const token = jwt.sign(
|
||||
{ openid, userId: user._id },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
openid,
|
||||
isNewUser,
|
||||
userInfo: {
|
||||
nickName: user.nickName,
|
||||
avatarUrl: user.avatarUrl
|
||||
},
|
||||
firstPlayDate: user.createdAt
|
||||
}, isNewUser ? '注册成功' : '登录成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('登录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* WebView 静默登录
|
||||
* POST /api/auth/webview-login
|
||||
* 用于微信小程序 webview 内嵌 H5 的自动登录
|
||||
*/
|
||||
router.post('/webview-login', async (req, res) => {
|
||||
try {
|
||||
const { code, userInfo } = req.body
|
||||
|
||||
if (!code) {
|
||||
return ApiResponse.error(res, '缺少登录凭证', 400)
|
||||
}
|
||||
|
||||
// 通过 code 获取 openid
|
||||
let openid = null
|
||||
try {
|
||||
const sessionData = await weChatService.code2Session(code)
|
||||
openid = sessionData.openid
|
||||
} catch (error) {
|
||||
logger.error('WebView登录获取session失败:', error)
|
||||
return ApiResponse.error(res, '登录凭证无效', 401)
|
||||
}
|
||||
|
||||
if (!openid) {
|
||||
return ApiResponse.error(res, '无法获取用户标识', 400)
|
||||
}
|
||||
|
||||
// 查找或创建用户
|
||||
let user = await User.findOne({ openid })
|
||||
let isNewUser = false
|
||||
|
||||
if (!user) {
|
||||
// 新用户 - 使用微信提供的信息创建账户
|
||||
user = new User({
|
||||
openid,
|
||||
nickName: userInfo?.nickName || '星辰旅行者',
|
||||
avatarUrl: userInfo?.avatarUrl || '',
|
||||
unlockedDims: [1]
|
||||
})
|
||||
isNewUser = true
|
||||
logger.info(`WebView新用户注册: ${openid}`)
|
||||
} else {
|
||||
// 老用户,更新登录时间和信息
|
||||
user.lastLoginAt = new Date()
|
||||
if (userInfo?.nickName) user.nickName = userInfo.nickName
|
||||
if (userInfo?.avatarUrl) user.avatarUrl = userInfo.avatarUrl
|
||||
logger.info(`WebView老用户登录: ${openid}`)
|
||||
}
|
||||
|
||||
await user.save()
|
||||
|
||||
// 记录登录日志
|
||||
await recordLoginLog(
|
||||
user._id,
|
||||
openid,
|
||||
isNewUser ? 'register' : 'auto_login',
|
||||
'wechat_webview',
|
||||
'webview',
|
||||
req,
|
||||
true
|
||||
)
|
||||
|
||||
// 生成 JWT token
|
||||
const token = jwt.sign(
|
||||
{ openid, userId: user._id },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
openid,
|
||||
isNewUser,
|
||||
userInfo: {
|
||||
nickName: user.nickName,
|
||||
avatarUrl: user.avatarUrl
|
||||
},
|
||||
firstPlayDate: user.createdAt
|
||||
}, isNewUser ? '注册成功' : '自动登录成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('WebView登录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 管理员登录
|
||||
* POST /api/auth/admin/login
|
||||
*/
|
||||
router.post('/admin/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body
|
||||
|
||||
// 查找管理员
|
||||
const admin = await Admin.findOne({ username })
|
||||
if (!admin) {
|
||||
return ApiResponse.error(res, '用户名或密码错误', 401)
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isMatch = await admin.validatePassword(password)
|
||||
if (!isMatch) {
|
||||
return ApiResponse.error(res, '用户名或密码错误', 401)
|
||||
}
|
||||
|
||||
// 检查状态
|
||||
if (admin.status !== 'active') {
|
||||
return ApiResponse.error(res, '账户已禁用', 403)
|
||||
}
|
||||
|
||||
// 更新登录信息
|
||||
admin.lastLogin = new Date()
|
||||
admin.lastLoginIp = req.ip
|
||||
admin.loginCount += 1
|
||||
await admin.save()
|
||||
|
||||
// 记录日志
|
||||
await AdminLog.create({
|
||||
adminId: admin._id,
|
||||
adminName: admin.username,
|
||||
action: 'login',
|
||||
resource: 'system',
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent')
|
||||
})
|
||||
|
||||
// 生成 JWT token
|
||||
const token = jwt.sign(
|
||||
{ adminId: admin._id, role: admin.role },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
// 获取权限
|
||||
const permissions = Admin.getPermissionsByRole(admin.role)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
adminId: admin._id,
|
||||
username: admin.username,
|
||||
role: admin.role,
|
||||
permissions
|
||||
}, '登录成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('管理员登录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* H5环境用户名密码登录
|
||||
* POST /api/auth/user/login
|
||||
*/
|
||||
router.post('/user/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body
|
||||
|
||||
// 查找用户(显式包含密码字段)
|
||||
const user = await User.findOne({ username }).select('+password')
|
||||
if (!user) {
|
||||
return ApiResponse.error(res, '用户名或密码错误', 401)
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isMatch = await user.validatePassword(password)
|
||||
if (!isMatch) {
|
||||
return ApiResponse.error(res, '用户名或密码错误', 401)
|
||||
}
|
||||
|
||||
// 更新登录时间
|
||||
user.lastLoginAt = new Date()
|
||||
await user.save()
|
||||
|
||||
// 生成 JWT token
|
||||
const token = jwt.sign(
|
||||
{ userId: user._id, openid: user.openid },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
userId: user._id,
|
||||
openid: user.openid,
|
||||
userInfo: {
|
||||
nickName: user.nickName,
|
||||
avatarUrl: user.avatarUrl
|
||||
},
|
||||
firstPlayDate: user.createdAt
|
||||
}, '登录成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('用户登录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* H5环境用户注册
|
||||
* POST /api/auth/user/register
|
||||
*/
|
||||
router.post('/user/register', async (req, res) => {
|
||||
try {
|
||||
const { username, password, nickName, avatarUrl } = req.body
|
||||
|
||||
// 检查用户名是否已存在
|
||||
const existingUser = await User.findOne({ username })
|
||||
if (existingUser) {
|
||||
return ApiResponse.error(res, '用户名已存在', 400)
|
||||
}
|
||||
|
||||
// 创建新用户
|
||||
const user = new User({
|
||||
username,
|
||||
password,
|
||||
nickName: nickName || '星辰旅行者',
|
||||
avatarUrl: avatarUrl || '',
|
||||
unlockedDims: [1]
|
||||
})
|
||||
await user.save()
|
||||
|
||||
logger.info(`新用户注册: ${username}`)
|
||||
|
||||
// 生成 JWT token
|
||||
const token = jwt.sign(
|
||||
{ userId: user._id },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
userId: user._id,
|
||||
userInfo: {
|
||||
nickName: user.nickName,
|
||||
avatarUrl: user.avatarUrl
|
||||
}
|
||||
}, '注册成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('用户注册失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
* GET /api/auth/me
|
||||
*/
|
||||
router.get('/me', async (req, res) => {
|
||||
try {
|
||||
const token = req.header('Authorization')?.replace('Bearer ', '')
|
||||
|
||||
if (!token) {
|
||||
return ApiResponse.unauthorized(res)
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
const user = await User.findById(decoded.userId)
|
||||
|
||||
if (!user) {
|
||||
return ApiResponse.notFound(res, '用户不存在')
|
||||
}
|
||||
|
||||
return ApiResponse.success(res, user)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取用户信息失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* WebView Token 交换接口
|
||||
* POST /api/auth/webview-token
|
||||
* 用于小程序 WebView 内嵌 H5 时,通过 openid 获取登录 token
|
||||
* 小程序在 URL 中传递 openid,H5 用这个接口换取 token
|
||||
*/
|
||||
router.post('/webview-token', async (req, res) => {
|
||||
try {
|
||||
const { openid } = req.body
|
||||
|
||||
if (!openid) {
|
||||
return ApiResponse.error(res, '缺少用户标识', 400)
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
const user = await User.findOne({ openid })
|
||||
|
||||
if (!user) {
|
||||
return ApiResponse.error(res, '用户不存在', 404)
|
||||
}
|
||||
|
||||
// 生成新的 JWT token
|
||||
const token = jwt.sign(
|
||||
{ openid, userId: user._id },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
// 更新最后登录时间
|
||||
user.lastLoginAt = new Date()
|
||||
await user.save()
|
||||
|
||||
// 记录登录日志
|
||||
await recordLoginLog(
|
||||
user._id,
|
||||
openid,
|
||||
'webview_auto_login',
|
||||
'wechat_webview',
|
||||
'webview_h5',
|
||||
req,
|
||||
true
|
||||
)
|
||||
|
||||
logger.info(`WebView Token交换成功: ${openid}`)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
openid,
|
||||
userInfo: {
|
||||
nickName: user.nickName,
|
||||
avatarUrl: user.avatarUrl
|
||||
}
|
||||
}, '登录成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('WebView Token交换失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
Executable
+201
@@ -0,0 +1,201 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { BGM } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { authMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 获取指定维度的随机BGM(公开接口,移动端调用)
|
||||
* GET /api/bgm/random/:dimension
|
||||
*/
|
||||
router.get('/random/:dimension', async (req, res) => {
|
||||
try {
|
||||
const dimension = parseInt(req.params.dimension)
|
||||
|
||||
if (![1, 2, 3, 4, 5].includes(dimension)) {
|
||||
return ApiResponse.badRequest(res, '无效的维度参数')
|
||||
}
|
||||
|
||||
// 查询该维度下所有激活的BGM
|
||||
const bgms = await BGM.find({
|
||||
dimension,
|
||||
isActive: true
|
||||
}).sort({ sortOrder: 1 })
|
||||
|
||||
if (!bgms || bgms.length === 0) {
|
||||
return ApiResponse.success(res, null)
|
||||
}
|
||||
|
||||
// 随机选择一个
|
||||
const randomIndex = Math.floor(Math.random() * bgms.length)
|
||||
const selectedBGM = bgms[randomIndex]
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
id: selectedBGM._id,
|
||||
name: selectedBGM.name,
|
||||
url: selectedBGM.url,
|
||||
duration: selectedBGM.duration,
|
||||
loop: selectedBGM.loop,
|
||||
volume: selectedBGM.volume,
|
||||
dimension: selectedBGM.dimension
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取随机BGM失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取所有BGM列表(管理员)
|
||||
* GET /api/admin/bgm/list
|
||||
*/
|
||||
router.get('/list', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { dimension, page = 1, limit = 20 } = req.query
|
||||
|
||||
const query = {}
|
||||
if (dimension && [1, 2, 3, 4, 5].includes(parseInt(dimension))) {
|
||||
query.dimension = parseInt(dimension)
|
||||
}
|
||||
|
||||
const pageNum = parseInt(page)
|
||||
const limitNum = parseInt(limit)
|
||||
const skip = (pageNum - 1) * limitNum
|
||||
|
||||
const [bgms, total] = await Promise.all([
|
||||
BGM.find(query)
|
||||
.sort({ dimension: 1, sortOrder: 1, createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limitNum),
|
||||
BGM.countDocuments(query)
|
||||
])
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
list: bgms,
|
||||
total,
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
totalPages: Math.ceil(total / limitNum)
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取BGM列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 创建BGM(管理员)
|
||||
* POST /api/admin/bgm/create
|
||||
*/
|
||||
router.post('/create', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { name, dimension, url, duration, loop, volume, sortOrder, description } = req.body
|
||||
|
||||
if (!name || !dimension || !url) {
|
||||
return ApiResponse.badRequest(res, '名称、维度和URL为必填项')
|
||||
}
|
||||
|
||||
if (![1, 2, 3, 4, 5].includes(parseInt(dimension))) {
|
||||
return ApiResponse.badRequest(res, '无效的维度参数')
|
||||
}
|
||||
|
||||
const bgm = new BGM({
|
||||
name,
|
||||
dimension: parseInt(dimension),
|
||||
url,
|
||||
duration: duration || 0,
|
||||
loop: loop !== undefined ? loop : true,
|
||||
volume: volume !== undefined ? volume : 0.5,
|
||||
sortOrder: sortOrder || 0,
|
||||
description: description || ''
|
||||
})
|
||||
|
||||
await bgm.save()
|
||||
|
||||
logger.info(`管理员创建了BGM: ${name} (维度${dimension})`)
|
||||
return ApiResponse.success(res, { message: 'BGM创建成功', data: bgm })
|
||||
} catch (error) {
|
||||
logger.error('创建BGM失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 更新BGM(管理员)
|
||||
* PUT /api/admin/bgm/update/:id
|
||||
*/
|
||||
router.put('/update/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const updates = req.body
|
||||
|
||||
const bgm = await BGM.findByIdAndUpdate(
|
||||
id,
|
||||
{ $set: updates },
|
||||
{ new: true, runValidators: true }
|
||||
)
|
||||
|
||||
if (!bgm) {
|
||||
return ApiResponse.notFound(res, 'BGM不存在')
|
||||
}
|
||||
|
||||
logger.info(`管理员更新了BGM: ${bgm.name}`)
|
||||
return ApiResponse.success(res, { message: 'BGM更新成功', data: bgm })
|
||||
} catch (error) {
|
||||
logger.error('更新BGM失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 删除BGM(管理员)
|
||||
* DELETE /api/admin/bgm/delete/:id
|
||||
*/
|
||||
router.delete('/delete/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const bgm = await BGM.findByIdAndDelete(id)
|
||||
|
||||
if (!bgm) {
|
||||
return ApiResponse.notFound(res, 'BGM不存在')
|
||||
}
|
||||
|
||||
logger.info(`管理员删除了BGM: ${bgm.name}`)
|
||||
return ApiResponse.success(res, { message: 'BGM删除成功' })
|
||||
} catch (error) {
|
||||
logger.error('删除BGM失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 切换BGM激活状态(管理员)
|
||||
* PUT /api/admin/bgm/toggle/:id
|
||||
*/
|
||||
router.put('/toggle/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const bgm = await BGM.findById(id)
|
||||
|
||||
if (!bgm) {
|
||||
return ApiResponse.notFound(res, 'BGM不存在')
|
||||
}
|
||||
|
||||
bgm.isActive = !bgm.isActive
|
||||
await bgm.save()
|
||||
|
||||
logger.info(`管理员${bgm.isActive ? '激活' : '禁用'}了BGM: ${bgm.name}`)
|
||||
return ApiResponse.success(res, {
|
||||
message: `BGM已${bgm.isActive ? '激活' : '禁用'}`,
|
||||
data: bgm
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('切换BGM状态失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
Executable
+228
@@ -0,0 +1,228 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { Feedback, User } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 获取客户端IP
|
||||
*/
|
||||
const getClientIp = (req) => {
|
||||
return req.headers['x-forwarded-for'] ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.socket.remoteAddress ||
|
||||
'0.0.0.0'
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交反馈
|
||||
* POST /api/feedback
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
images,
|
||||
relatedPage,
|
||||
deviceInfo,
|
||||
userContact
|
||||
} = req.body
|
||||
|
||||
// 验证必填字段
|
||||
if (!type || !title || !content) {
|
||||
return ApiResponse.error(res, '请填写完整的反馈信息', 400)
|
||||
}
|
||||
|
||||
// 获取用户信息(如果已登录)
|
||||
let userId = null
|
||||
let openid = null
|
||||
let userName = ''
|
||||
|
||||
try {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '')
|
||||
if (token) {
|
||||
const jwt = require('jsonwebtoken')
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
userId = decoded.userId
|
||||
openid = decoded.openid
|
||||
|
||||
// 获取用户信息
|
||||
const user = await User.findById(userId)
|
||||
if (user) {
|
||||
userName = user.nickName || ''
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Token 验证失败,继续以匿名方式提交
|
||||
logger.warn('反馈提交时Token验证失败:', error.message)
|
||||
}
|
||||
|
||||
// 创建反馈记录
|
||||
const feedback = new Feedback({
|
||||
userId,
|
||||
openid,
|
||||
userName,
|
||||
userContact: userContact || '',
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
images: images || [],
|
||||
relatedPage: relatedPage || '',
|
||||
deviceInfo: deviceInfo || {},
|
||||
networkInfo: {
|
||||
ip: getClientIp(req)
|
||||
},
|
||||
status: 'pending',
|
||||
priority: 'normal'
|
||||
})
|
||||
|
||||
await feedback.save()
|
||||
|
||||
logger.info(`新反馈提交: ${feedback._id}, 类型: ${type}`)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
feedbackId: feedback._id
|
||||
}, '反馈提交成功,我们会尽快处理')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('提交反馈失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取我的反馈列表
|
||||
* GET /api/feedback/my
|
||||
*/
|
||||
router.get('/my', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { page = 1, limit = 20 } = req.query
|
||||
|
||||
const query = { userId: user._id }
|
||||
|
||||
const total = await Feedback.countDocuments(query)
|
||||
const feedbacks = await Feedback.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((parseInt(page) - 1) * parseInt(limit))
|
||||
.limit(parseInt(limit))
|
||||
.select('-deviceInfo -networkInfo')
|
||||
.lean()
|
||||
|
||||
// 格式化返回数据
|
||||
const formattedFeedbacks = feedbacks.map(fb => ({
|
||||
id: fb._id,
|
||||
type: fb.type,
|
||||
title: fb.title,
|
||||
content: fb.content.substring(0, 100) + (fb.content.length > 100 ? '...' : ''),
|
||||
images: fb.images,
|
||||
status: fb.status,
|
||||
priority: fb.priority,
|
||||
result: fb.result,
|
||||
createdAt: fb.createdAt,
|
||||
updatedAt: fb.updatedAt
|
||||
}))
|
||||
|
||||
return ApiResponse.paginated(res, formattedFeedbacks, {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取我的反馈失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取反馈详情
|
||||
* GET /api/feedback/:id
|
||||
*/
|
||||
router.get('/:id', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const feedback = await Feedback.findById(req.params.id).lean()
|
||||
|
||||
if (!feedback) {
|
||||
return ApiResponse.notFound(res, '反馈不存在')
|
||||
}
|
||||
|
||||
// 检查权限(只能查看自己的反馈)
|
||||
if (feedback.userId?.toString() !== user._id.toString()) {
|
||||
return ApiResponse.error(res, '无权查看此反馈', 403)
|
||||
}
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
id: feedback._id,
|
||||
type: feedback.type,
|
||||
title: feedback.title,
|
||||
content: feedback.content,
|
||||
images: feedback.images,
|
||||
relatedPage: feedback.relatedPage,
|
||||
status: feedback.status,
|
||||
priority: feedback.priority,
|
||||
processLog: feedback.processLog,
|
||||
result: feedback.result,
|
||||
handler: feedback.handler,
|
||||
handledAt: feedback.handledAt,
|
||||
rating: feedback.rating,
|
||||
createdAt: feedback.createdAt,
|
||||
updatedAt: feedback.updatedAt
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取反馈详情失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 对处理结果评分
|
||||
* POST /api/feedback/:id/rate
|
||||
*/
|
||||
router.post('/:id/rate', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { rating, remark } = req.body
|
||||
|
||||
if (!rating || rating < 1 || rating > 5) {
|
||||
return ApiResponse.error(res, '评分必须在1-5之间', 400)
|
||||
}
|
||||
|
||||
const feedback = await Feedback.findById(req.params.id)
|
||||
|
||||
if (!feedback) {
|
||||
return ApiResponse.notFound(res, '反馈不存在')
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (feedback.userId?.toString() !== user._id.toString()) {
|
||||
return ApiResponse.error(res, '无权操作此反馈', 403)
|
||||
}
|
||||
|
||||
// 只能对已解决的反馈评分
|
||||
if (feedback.status !== 'resolved' && feedback.status !== 'closed') {
|
||||
return ApiResponse.error(res, '只能对已处理的反馈进行评分', 400)
|
||||
}
|
||||
|
||||
feedback.rating = rating
|
||||
if (remark) {
|
||||
feedback.userRemark = remark
|
||||
}
|
||||
|
||||
await feedback.save()
|
||||
|
||||
return ApiResponse.success(res, {}, '评分成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('反馈评分失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
Executable
+209
@@ -0,0 +1,209 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { Gallery } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware, optionalAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
// 应用用户认证中间件到需要登录的路由
|
||||
// 注意:POST /api/gallery 需要认证
|
||||
router.post('/', userAuthMiddleware)
|
||||
router.use('/like', userAuthMiddleware)
|
||||
// 作品列表接口使用可选认证,这样未登录用户也能访问
|
||||
router.get('/works', optionalAuthMiddleware)
|
||||
|
||||
/**
|
||||
* 获取作品列表(兼容旧版API)
|
||||
* GET /api/gallery/works
|
||||
*/
|
||||
router.get('/works', async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 12, filter = 'all' } = req.query
|
||||
|
||||
const query = { status: 'approved' }
|
||||
|
||||
// 处理筛选条件
|
||||
if (filter === 'dim2') {
|
||||
query.dim = 2
|
||||
} else if (filter === 'dim3') {
|
||||
query.dim = 3
|
||||
} else if (filter === 'my') {
|
||||
// 显示当前用户的作品,需要认证
|
||||
if (!req.user) {
|
||||
return ApiResponse.error(res, '需要登录才能查看我的作品', 401)
|
||||
}
|
||||
query.openid = req.user.openid
|
||||
}
|
||||
|
||||
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,
|
||||
fileURL: work.imageUrl || work.imageData,
|
||||
authorAvatar: work.authorAvatar || '/static/images/default_avatar.png',
|
||||
authorName: work.authorName || '星辰旅者',
|
||||
likes: work.likeCount || 0,
|
||||
views: work.viewCount || 0,
|
||||
dim: work.dim || 2,
|
||||
type: work.dim === 2 ? 'dim2' : 'dim3',
|
||||
description: work.description || '',
|
||||
createdAt: work.createdAt
|
||||
}))
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
list: formattedWorks,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit)
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取作品列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取作品列表
|
||||
* GET /api/gallery
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, status = 'approved', dim } = req.query
|
||||
|
||||
const query = { status }
|
||||
if (dim) query.dim = parseInt(dim)
|
||||
|
||||
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()
|
||||
|
||||
return ApiResponse.paginated(res, works, {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取作品列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 上传作品
|
||||
* POST /api/gallery
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { title, description, imageData, imageUrl, tags, dim } = req.body
|
||||
|
||||
if (!title || !dim) {
|
||||
return ApiResponse.error(res, '标题和维度为必填项', 400)
|
||||
}
|
||||
|
||||
const work = new Gallery({
|
||||
openid: user.openid,
|
||||
userId: user._id,
|
||||
authorName: user.nickName,
|
||||
authorAvatar: user.avatarUrl,
|
||||
title,
|
||||
description,
|
||||
imageData,
|
||||
imageUrl,
|
||||
tags: tags || [],
|
||||
dim,
|
||||
status: 'approved' // 自动审核通过,用户可以立即看到自己的作品
|
||||
})
|
||||
|
||||
await work.save()
|
||||
|
||||
return ApiResponse.success(res, work, '作品上传成功,等待审核')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('上传作品失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取作品详情
|
||||
* GET /api/gallery/:id
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const work = await Gallery.findById(req.params.id)
|
||||
|
||||
if (!work) {
|
||||
return ApiResponse.notFound(res, '作品不存在')
|
||||
}
|
||||
|
||||
// 增加浏览量
|
||||
work.viewCount += 1
|
||||
await work.save()
|
||||
|
||||
return ApiResponse.success(res, work)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取作品详情失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 点赞作品(兼容移动端API)
|
||||
* POST /api/gallery/like/:id
|
||||
*/
|
||||
router.post('/like/:id', async (req, res) => {
|
||||
try {
|
||||
const work = await Gallery.findById(req.params.id)
|
||||
|
||||
if (!work) {
|
||||
return ApiResponse.notFound(res, '作品不存在')
|
||||
}
|
||||
|
||||
work.likeCount += 1
|
||||
await work.save()
|
||||
|
||||
return ApiResponse.success(res, { likeCount: work.likeCount }, '点赞成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('点赞失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 点赞作品
|
||||
* POST /api/gallery/:id/like
|
||||
*/
|
||||
router.post('/:id/like', async (req, res) => {
|
||||
try {
|
||||
const work = await Gallery.findById(req.params.id)
|
||||
|
||||
if (!work) {
|
||||
return ApiResponse.notFound(res, '作品不存在')
|
||||
}
|
||||
|
||||
work.likeCount += 1
|
||||
await work.save()
|
||||
|
||||
return ApiResponse.success(res, { likeCount: work.likeCount }, '点赞成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('点赞失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
const authRoutes = require('./auth')
|
||||
const userRoutes = require('./user')
|
||||
const paymentRoutes = require('./payment')
|
||||
const galleryRoutes = require('./gallery')
|
||||
const knowledgeRoutes = require('./knowledge')
|
||||
const adminRoutes = require('./admin')
|
||||
const shareRoutes = require('./share')
|
||||
const bgmRoutes = require('./bgm')
|
||||
const aiChatRoutes = require('./aiChat')
|
||||
const aiModelRoutes = require('./aiModel')
|
||||
const feedbackRoutes = require('./feedback')
|
||||
const pinyinRoutes = require('./pinyin')
|
||||
const trendRoutes = require('./trend')
|
||||
|
||||
module.exports = (app) => {
|
||||
// 认证路由
|
||||
app.use('/api/auth', authRoutes)
|
||||
|
||||
// 用户路由
|
||||
app.use('/api/user', userRoutes)
|
||||
|
||||
// 支付路由
|
||||
app.use('/api/payment', paymentRoutes)
|
||||
|
||||
// 画廊路由
|
||||
app.use('/api/gallery', galleryRoutes)
|
||||
|
||||
// 知识库路由
|
||||
app.use('/api/knowledge', knowledgeRoutes)
|
||||
|
||||
// 反馈路由
|
||||
app.use('/api/feedback', feedbackRoutes)
|
||||
|
||||
// 管理后台路由
|
||||
app.use('/api/admin', adminRoutes)
|
||||
|
||||
// 分享记录路由
|
||||
app.use('/api/share', shareRoutes)
|
||||
|
||||
// BGM音效路由(公开接口 + 管理员接口)
|
||||
app.use('/api/bgm', bgmRoutes)
|
||||
|
||||
// AI问答路由
|
||||
app.use('/api/ai-chat', aiChatRoutes)
|
||||
|
||||
// AI模型配置路由
|
||||
app.use('/api/ai-model', aiModelRoutes)
|
||||
|
||||
// 拼音探索路由
|
||||
app.use('/api/pinyin/contents', pinyinRoutes.contents)
|
||||
app.use('/api/pinyin/progress', pinyinRoutes.progress)
|
||||
app.use('/api/pinyin/games', pinyinRoutes.games)
|
||||
app.use('/api/pinyin/achievements', pinyinRoutes.achievements)
|
||||
|
||||
// AI趋势路由
|
||||
app.use('/api/trend', trendRoutes)
|
||||
}
|
||||
Executable
+186
@@ -0,0 +1,186 @@
|
||||
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
|
||||
*/
|
||||
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
|
||||
Executable
+266
@@ -0,0 +1,266 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { User, Order, ShopItem } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const weChatService = require('../utils/weixin')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 获取商品列表(从数据库)- 公开接口
|
||||
* GET /api/payment/products
|
||||
*/
|
||||
router.get('/products', async (req, res) => {
|
||||
try {
|
||||
const items = await ShopItem.find({ status: 'active' }).sort({ sort: 1 })
|
||||
return ApiResponse.success(res, items)
|
||||
} catch (error) {
|
||||
logger.error('获取商品列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
// 应用用户认证中间件到需要保护的路由
|
||||
router.use(userAuthMiddleware)
|
||||
|
||||
/**
|
||||
* 创建订单
|
||||
* POST /api/payment/create-order
|
||||
*/
|
||||
router.post('/create-order', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { productId } = req.body
|
||||
|
||||
// 从数据库获取商品信息
|
||||
const product = await ShopItem.findOne({ itemId: productId, status: 'active' })
|
||||
if (!product) {
|
||||
return ApiResponse.error(res, '无效商品 ID 或商品已下架', 400)
|
||||
}
|
||||
|
||||
// 检查是否已购买(针对皮肤类一次性商品)
|
||||
if (product.type === 'skin' && user.ownedSkins?.includes(productId)) {
|
||||
return ApiResponse.error(res, '已拥有该商品', 400)
|
||||
}
|
||||
|
||||
// 生成订单号
|
||||
const orderNo = weChatService.generateOrderNo('WD')
|
||||
|
||||
// 创建订单记录
|
||||
const order = new Order({
|
||||
orderNo,
|
||||
openid: user.openid,
|
||||
userId: user._id,
|
||||
productId,
|
||||
productName: product.name,
|
||||
amount: product.price,
|
||||
status: 'pending'
|
||||
})
|
||||
await order.save()
|
||||
|
||||
// 调用微信支付统一下单
|
||||
const payParams = await weChatService.createPaymentOrder({
|
||||
openid: user.openid,
|
||||
orderNo,
|
||||
body: `宇之然-${product.name}`,
|
||||
amount: product.price
|
||||
})
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
orderNo,
|
||||
productId,
|
||||
productName: product.name,
|
||||
amount: product.price,
|
||||
payParams
|
||||
}, '订单创建成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('创建订单失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 支付回调
|
||||
* POST /api/payment/callback
|
||||
*/
|
||||
router.post('/callback', async (req, res) => {
|
||||
try {
|
||||
const data = req.body
|
||||
|
||||
// 验证签名
|
||||
if (!weChatService.verifyCallbackSignature(data, data.sign)) {
|
||||
logger.warn('支付签名验证失败')
|
||||
return res.status(400).send('FAIL')
|
||||
}
|
||||
|
||||
const orderNo = data.out_trade_no
|
||||
const transactionId = data.transaction_id
|
||||
|
||||
// 查询订单
|
||||
const order = await Order.findOne({ orderNo })
|
||||
if (!order) {
|
||||
logger.error(`订单不存在: ${orderNo}`)
|
||||
return res.status(400).send('FAIL')
|
||||
}
|
||||
|
||||
// 已处理过
|
||||
if (order.status === 'paid' || order.status === 'completed') {
|
||||
return res.status(200).send('SUCCESS')
|
||||
}
|
||||
|
||||
// 更新订单状态
|
||||
order.status = 'paid'
|
||||
order.transactionId = transactionId
|
||||
order.paidAt = new Date()
|
||||
await order.save()
|
||||
|
||||
// 解锁商品
|
||||
const user = await User.findOne({ openid: order.openid })
|
||||
if (user) {
|
||||
const product = await ShopItem.findOne({ itemId: order.productId })
|
||||
|
||||
if (product) {
|
||||
if (product.type === 'skin') {
|
||||
if (!user.ownedSkins.includes(order.productId)) {
|
||||
user.ownedSkins.push(order.productId)
|
||||
}
|
||||
} else if (product.type === 'noad') {
|
||||
user.isAdFree = true
|
||||
} else if (product.type === 'subscription') {
|
||||
const expiry = new Date()
|
||||
expiry.setDate(expiry.getDate() + (product.duration || 30))
|
||||
user.isSubscriber = true
|
||||
user.subscribeExpiry = expiry
|
||||
}
|
||||
await user.save()
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`支付成功: ${orderNo}`)
|
||||
return res.status(200).send('SUCCESS')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('支付回调处理失败:', error)
|
||||
return res.status(500).send('FAIL')
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 验证支付结果
|
||||
* POST /api/payment/verify
|
||||
*/
|
||||
router.post('/verify', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { productId, orderNo } = req.body
|
||||
|
||||
// 验证订单是否已处理
|
||||
const order = await Order.findOne({
|
||||
orderNo,
|
||||
openid: user.openid,
|
||||
status: { $in: ['paid', 'completed'] }
|
||||
})
|
||||
|
||||
if (order) {
|
||||
return ApiResponse.success(res, {
|
||||
alreadyProcessed: true,
|
||||
message: '订单已处理'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取商品信息
|
||||
const product = await ShopItem.findOne({ itemId: productId, status: 'active' })
|
||||
if (!product) {
|
||||
return ApiResponse.error(res, `未知商品: ${productId}`, 400)
|
||||
}
|
||||
|
||||
// 更新用户商品
|
||||
let updateData = {}
|
||||
|
||||
if (product.type === 'skin') {
|
||||
if (!user.ownedSkins.includes(productId)) {
|
||||
user.ownedSkins.push(productId)
|
||||
}
|
||||
} else if (product.type === 'noad') {
|
||||
user.isAdFree = true
|
||||
} else if (product.type === 'subscription') {
|
||||
const expiry = new Date()
|
||||
expiry.setDate(expiry.getDate() + (product.duration || 30))
|
||||
user.isSubscriber = true
|
||||
user.subscribeExpiry = expiry
|
||||
}
|
||||
|
||||
await user.save()
|
||||
|
||||
// 记录订单
|
||||
const newOrder = new Order({
|
||||
orderNo,
|
||||
openid: user.openid,
|
||||
userId: user._id,
|
||||
productId,
|
||||
productName: product.name,
|
||||
amount: product.price,
|
||||
status: 'completed',
|
||||
completedAt: new Date()
|
||||
})
|
||||
await newOrder.save()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
product,
|
||||
message: `${product.name} 已解锁!`
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('验证支付失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* 申请退款
|
||||
* POST /api/payment/refund
|
||||
*/
|
||||
router.post('/refund', async (req, res) => {
|
||||
try {
|
||||
const { orderNo, reason } = req.body
|
||||
|
||||
// 查询订单
|
||||
const order = await Order.findOne({ orderNo })
|
||||
if (!order) {
|
||||
return ApiResponse.error(res, '订单不存在', 404)
|
||||
}
|
||||
|
||||
if (order.status !== 'paid' && order.status !== 'completed') {
|
||||
return ApiResponse.error(res, '订单状态不支持退款', 400)
|
||||
}
|
||||
|
||||
// 调用微信退款接口
|
||||
const result = await weChatService.refundOrder(
|
||||
order.transactionId,
|
||||
`REF_${order.orderNo}`,
|
||||
order.amount / 100,
|
||||
order.amount / 100,
|
||||
reason
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
// 更新订单状态
|
||||
order.status = 'refunded'
|
||||
order.refundReason = reason
|
||||
order.refundedAt = new Date()
|
||||
await order.save()
|
||||
|
||||
return ApiResponse.success(res, result, '退款申请已提交')
|
||||
} else {
|
||||
return ApiResponse.error(res, '退款申请失败', 500)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('申请退款失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authMiddleware } = require('../../middleware/auth');
|
||||
const achievementController = require('../../controllers/pinyin/achievementController');
|
||||
|
||||
// 获取成就列表(公开)
|
||||
router.get('/', achievementController.getAchievements);
|
||||
|
||||
// 获取我的成就(需要登录)
|
||||
router.get('/my', authMiddleware, achievementController.getMyAchievements);
|
||||
|
||||
// 检查成就达成(需要登录)
|
||||
router.post('/check', authMiddleware, achievementController.checkAchievements);
|
||||
|
||||
module.exports = router;
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authMiddleware } = require('../../middleware/auth');
|
||||
const contentController = require('../../controllers/pinyin/contentController');
|
||||
|
||||
// 公开接口
|
||||
router.get('/', contentController.getContents);
|
||||
router.get('/:symbol', contentController.getContentBySymbol);
|
||||
router.get('/:symbol/audio', contentController.getContentAudio);
|
||||
|
||||
// 管理员接口
|
||||
router.post('/', authMiddleware, contentController.createContent);
|
||||
router.put('/:id', authMiddleware, contentController.updateContent);
|
||||
router.delete('/:id', authMiddleware, contentController.deleteContent);
|
||||
|
||||
module.exports = router;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { userAuthMiddleware } = require('../../middleware/auth');
|
||||
const gameController = require('../../controllers/pinyin/gameController');
|
||||
|
||||
// 获取游戏配置
|
||||
router.get('/config', gameController.getGameConfig);
|
||||
|
||||
// 获取游戏配置(按类型)
|
||||
router.get('/config/:type', gameController.getGameConfigByType);
|
||||
|
||||
// 获取用户游戏统计(需要登录)
|
||||
router.get('/stats', userAuthMiddleware, gameController.getGameStats);
|
||||
|
||||
// 获取用户游戏记录(需要登录)
|
||||
router.get('/records', userAuthMiddleware, gameController.getGameRecords);
|
||||
|
||||
// 记录游戏结果(需要登录)
|
||||
router.post('/record', userAuthMiddleware, gameController.recordGame);
|
||||
|
||||
// 获取游戏排行榜
|
||||
router.get('/leaderboard/:type', gameController.getLeaderboard);
|
||||
|
||||
module.exports = router;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 拼音探索模块路由导出
|
||||
*/
|
||||
const contentsRouter = require('./contents');
|
||||
const progressRouter = require('./progress');
|
||||
const gamesRouter = require('./games');
|
||||
const achievementsRouter = require('./achievements');
|
||||
|
||||
module.exports = {
|
||||
contents: contentsRouter,
|
||||
progress: progressRouter,
|
||||
games: gamesRouter,
|
||||
achievements: achievementsRouter
|
||||
};
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { userAuthMiddleware } = require('../../middleware/auth');
|
||||
const progressController = require('../../controllers/pinyin/progressController');
|
||||
|
||||
// 获取用户探索进度
|
||||
router.get('/', userAuthMiddleware, progressController.getProgress);
|
||||
|
||||
module.exports = router;
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { ShareRecord, User } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware, optionalAuthMiddleware, authMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 记录分享行为(支持匿名)
|
||||
* POST /api/share/record
|
||||
*/
|
||||
router.post('/record', optionalAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { shareType, score } = req.body
|
||||
const openid = req.user ? req.user.openid : 'anonymous'
|
||||
const userId = req.user ? req.user._id : null
|
||||
|
||||
const record = new ShareRecord({
|
||||
openid,
|
||||
userId,
|
||||
shareType: shareType || 'app',
|
||||
score: score || 0
|
||||
})
|
||||
await record.save()
|
||||
|
||||
return ApiResponse.success(res, { message: '分享已记录' })
|
||||
} catch (error) {
|
||||
logger.error('记录分享失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取分享记录列表(管理员)
|
||||
* GET /api/share/list
|
||||
*/
|
||||
router.get('/list', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, openid } = req.query
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const query = {}
|
||||
if (openid && openid !== 'all') {
|
||||
query.openid = openid
|
||||
}
|
||||
|
||||
const records = await ShareRecord.find(query)
|
||||
.sort({ sharedAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit))
|
||||
.populate('userId', 'nickname avatar')
|
||||
|
||||
const total = await ShareRecord.countDocuments(query)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
list: records,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit)
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取分享记录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -0,0 +1,83 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Trend = require('../models/Trend');
|
||||
|
||||
/**
|
||||
* 获取趋势列表
|
||||
* GET /api/trend?category=产品&page=1&limit=20
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { category, page = 1, limit = 20 } = req.query;
|
||||
const query = { status: 'published' };
|
||||
if (category) query.category = category;
|
||||
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
const [list, total] = await Promise.all([
|
||||
Trend.find(query)
|
||||
.sort({ newsDate: -1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit))
|
||||
.select('-__v'),
|
||||
Trend.countDocuments(query)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { list, total, page: parseInt(page), totalPages: Math.ceil(total / parseInt(limit)) }
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取趋势详情
|
||||
* GET /api/trend/:id
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const trend = await Trend.findByIdAndUpdate(
|
||||
req.params.id,
|
||||
{ $inc: { viewCount: 1 } },
|
||||
{ new: true }
|
||||
);
|
||||
if (!trend) return res.status(404).json({ success: false, error: '趋势不存在' });
|
||||
res.json({ success: true, data: trend });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 管理员:创建趋势
|
||||
* POST /api/trend/admin
|
||||
*/
|
||||
router.post('/admin', async (req, res) => {
|
||||
try {
|
||||
const trend = new Trend(req.body);
|
||||
await trend.save();
|
||||
res.json({ success: true, data: trend });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 管理员:批量创建趋势(给 cron 用)
|
||||
* POST /api/trend/admin/batch
|
||||
*/
|
||||
router.post('/admin/batch', async (req, res) => {
|
||||
try {
|
||||
const { items } = req.body;
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, error: 'items 不能为空' });
|
||||
}
|
||||
const result = await Trend.insertMany(items);
|
||||
res.json({ success: true, data: { inserted: result.length } });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Executable
+424
@@ -0,0 +1,424 @@
|
||||
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
|
||||
|
||||
// 生成成就数据
|
||||
const achievements = [
|
||||
{
|
||||
name: '探索成就',
|
||||
achievements: [
|
||||
{
|
||||
id: 'dim1_complete',
|
||||
name: '一维探索者',
|
||||
description: '完成一维空间的探索',
|
||||
icon: '⭐',
|
||||
unlocked: user.exploreData.dim1?.completed || false,
|
||||
date: user.exploreData.dim1?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'dim2_complete',
|
||||
name: '二维探索者',
|
||||
description: '完成二维空间的探索',
|
||||
icon: '✨',
|
||||
unlocked: user.exploreData.dim2?.completed || false,
|
||||
date: user.exploreData.dim2?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'dim3_complete',
|
||||
name: '三维探索者',
|
||||
description: '完成三维空间的探索',
|
||||
icon: '🌟',
|
||||
unlocked: user.exploreData.dim3?.completed || false,
|
||||
date: user.exploreData.dim3?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'dim4_complete',
|
||||
name: '四维探索者',
|
||||
description: '完成四维空间的探索',
|
||||
icon: '💫',
|
||||
unlocked: user.exploreData.dim4?.completed || false,
|
||||
date: user.exploreData.dim4?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'dim5_complete',
|
||||
name: '五维探索者',
|
||||
description: '完成五维空间的探索',
|
||||
icon: '🌌',
|
||||
unlocked: user.exploreData.dim5?.completed || false,
|
||||
date: user.exploreData.dim5?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '创作成就',
|
||||
achievements: [
|
||||
{
|
||||
id: 'first_work',
|
||||
name: '初次创作',
|
||||
description: '创作你的第一个作品',
|
||||
icon: '🎨',
|
||||
unlocked: user.achievements?.includes('first_work') || false,
|
||||
date: user.achievements?.includes('first_work') ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'five_works',
|
||||
name: '创作达人',
|
||||
description: '创作5个作品',
|
||||
icon: '🏆',
|
||||
unlocked: user.achievements?.includes('five_works') || false,
|
||||
date: user.achievements?.includes('five_works') ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'ten_works',
|
||||
name: '创作大师',
|
||||
description: '创作10个作品',
|
||||
icon: '👑',
|
||||
unlocked: user.achievements?.includes('ten_works') || false,
|
||||
date: user.achievements?.includes('ten_works') ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '收藏成就',
|
||||
achievements: [
|
||||
{
|
||||
id: 'first_collection',
|
||||
name: '初次收藏',
|
||||
description: '收藏你的第一个知识',
|
||||
icon: '❤️',
|
||||
unlocked: user.collectedKnowledge?.length > 0,
|
||||
date: user.collectedKnowledge?.length > 0 ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'five_collections',
|
||||
name: '收藏爱好者',
|
||||
description: '收藏5个知识',
|
||||
icon: '🧡',
|
||||
unlocked: user.collectedKnowledge?.length >= 5,
|
||||
date: user.collectedKnowledge?.length >= 5 ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'ten_collections',
|
||||
name: '收藏大师',
|
||||
description: '收藏10个知识',
|
||||
icon: '💛',
|
||||
unlocked: user.collectedKnowledge?.length >= 10,
|
||||
date: user.collectedKnowledge?.length >= 10 ? 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
|
||||
Reference in New Issue
Block a user