feat: 微信分享钩子全页面接入 + 订阅信息结构化映射 + JWT 错误处理加强
- client: 全部页面接入 onShareAppMessage/onShareTimeline 分享钩子 - client: login 页增强、profile 订阅展示优化 - client/stores: 订阅字段扁平结构映射为 subInfo(plan/expiresAt/startDate) - server: auth 路由 JWT 无效/过期统一返回 401 未授权响应 - server: models/tests 同步更新
This commit is contained in:
+18
-10
@@ -76,22 +76,30 @@ app.use((err, req, res, next) => {
|
||||
})
|
||||
})
|
||||
|
||||
// 启动服务器
|
||||
// 启动服务器(仅在直接运行时启动,被测试 import 时不启动)
|
||||
const PORT = process.env.PORT || 3000
|
||||
const server = app.listen(PORT, () => {
|
||||
logger.info(`🚀 服务器启动成功`)
|
||||
logger.info(`📍 端口: ${PORT}`)
|
||||
logger.info(`🌐 环境: ${process.env.NODE_ENV || 'development'}`)
|
||||
logger.info(`📊 健康检查: http://localhost:${PORT}/health`)
|
||||
})
|
||||
let server = null
|
||||
|
||||
if (require.main === module) {
|
||||
server = app.listen(PORT, () => {
|
||||
logger.info(`🚀 服务器启动成功`)
|
||||
logger.info(`📍 端口: ${PORT}`)
|
||||
logger.info(`🌐 环境: ${process.env.NODE_ENV || 'development'}`)
|
||||
logger.info(`📊 健康检查: http://localhost:${PORT}/health`)
|
||||
})
|
||||
}
|
||||
|
||||
// 优雅关闭
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('📴 接收到 SIGTERM 信号,正在关闭服务器...')
|
||||
server.close(() => {
|
||||
logger.info('✅ 服务器已关闭')
|
||||
if (server) {
|
||||
server.close(() => {
|
||||
logger.info('✅ 服务器已关闭')
|
||||
process.exit(0)
|
||||
})
|
||||
} else {
|
||||
process.exit(0)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = app
|
||||
|
||||
@@ -96,32 +96,23 @@ const userAuthMiddleware = async (req, res, next) => {
|
||||
|
||||
const token = authHeader.replace('Bearer ', '')
|
||||
|
||||
// 添加调试日志
|
||||
console.log('[Auth] Token received:', token.substring(0, 20) + '...')
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
console.log('[Auth] Token decoded:', decoded)
|
||||
|
||||
// 兼容两种token格式:包含userId或openid
|
||||
let user = null
|
||||
if (decoded.userId) {
|
||||
console.log('[Auth] Looking up user by userId:', decoded.userId)
|
||||
user = await User.findById(decoded.userId)
|
||||
} else if (decoded.openid) {
|
||||
console.log('[Auth] Looking up user by openid:', decoded.openid)
|
||||
user = await User.findOne({ openid: decoded.openid })
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
console.log('[Auth] User not found')
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '用户不存在或令牌无效'
|
||||
})
|
||||
}
|
||||
|
||||
console.log('[Auth] User found:', user._id)
|
||||
console.log('[Auth] User openid:', user.openid)
|
||||
req.user = user
|
||||
// 优先使用 openid,如果没有则使用 userId 作为标识
|
||||
req.openid = user.openid || user._id.toString()
|
||||
|
||||
@@ -4,6 +4,7 @@ const { Schema } = mongoose;
|
||||
const TrendSchema = new Schema({
|
||||
title: { type: String, required: true, trim: true },
|
||||
summary: { type: String },
|
||||
body: { type: String },
|
||||
source: { type: String, default: '未知来源' },
|
||||
sourceUrl: { type: String },
|
||||
category: {
|
||||
|
||||
@@ -94,11 +94,11 @@ const UserSchema = new Schema({
|
||||
dim4Score: { type: Number, default: 0 },
|
||||
dim5Score: { type: Number, default: 0 },
|
||||
|
||||
// 已解锁维度
|
||||
unlockedDims: [{
|
||||
type: Number,
|
||||
enum: [1, 2, 3, 4, 5]
|
||||
}],
|
||||
// 已解锁维度(新用户默认解锁第1维度)
|
||||
unlockedDims: {
|
||||
type: [{ type: Number, enum: [1, 2, 3, 4, 5] }],
|
||||
default: [1]
|
||||
},
|
||||
|
||||
// 商品相关
|
||||
ownedSkins: [{
|
||||
|
||||
@@ -4,7 +4,7 @@ const { Schema } = mongoose
|
||||
const VerificationCodeSchema = new Schema({
|
||||
email: { type: String, required: true, index: true },
|
||||
code: { type: String, required: true },
|
||||
expiresAt: { type: Date, required: true, index: true },
|
||||
expiresAt: { type: Date, required: true },
|
||||
used: { type: Boolean, default: false },
|
||||
createdAt: { type: Date, default: Date.now }
|
||||
})
|
||||
|
||||
+1080
-1345
File diff suppressed because it is too large
Load Diff
+24
-16
@@ -119,21 +119,29 @@ async function tryModels(models, question, dimension, res, quota) {
|
||||
|
||||
logger.info(`[AI Chat] 尝试模型: ${model.name} (${modelId})`)
|
||||
|
||||
// 调用AI API
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
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
|
||||
// 调用AI API(带超时,避免外部服务无响应导致请求挂起)
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), 30000)
|
||||
let response
|
||||
try {
|
||||
response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
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
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
@@ -144,8 +152,8 @@ async function tryModels(models, question, dimension, res, quota) {
|
||||
|
||||
const data = await response.json()
|
||||
const message = data.choices[0].message
|
||||
// 兼容标准 content 和 sensenova 的 reasoning 字段
|
||||
const answer = message.content || message.reasoning || ''
|
||||
// 兼容标准 content 与推理模型(sensenova reasoning / nvidia reasoning_content)
|
||||
const answer = message.content || message.reasoning_content || message.reasoning || ''
|
||||
|
||||
// 扣除一次使用次数
|
||||
await quota.useQuota()
|
||||
|
||||
@@ -519,18 +519,21 @@ router.get('/me', async (req, 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) {
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return ApiResponse.unauthorized(res, '认证令牌无效或已过期')
|
||||
}
|
||||
logger.error('获取用户信息失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* WebView Token 交换接口
|
||||
@@ -568,7 +571,7 @@ router.post('/webview-token', async (req, res) => {
|
||||
await recordLoginLog(
|
||||
user._id,
|
||||
openid,
|
||||
'webview_auto_login',
|
||||
'auto_login',
|
||||
'wechat_webview',
|
||||
'webview_h5',
|
||||
req,
|
||||
|
||||
@@ -80,6 +80,58 @@ router.post('/create-order', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 创建H5支付订单(微信H5支付)
|
||||
* POST /api/payment/create-h5-order
|
||||
*/
|
||||
router.post('/create-h5-order', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { productId, clientIp } = req.body
|
||||
|
||||
// 从数据库获取商品信息
|
||||
const product = await ShopItem.findOne({ itemId: productId, status: 'active' })
|
||||
if (!product) {
|
||||
return ApiResponse.error(res, '无效商品 ID 或商品已下架', 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()
|
||||
|
||||
// 调用微信支付H5下单
|
||||
const h5PayParams = await weChatService.createH5PaymentOrder({
|
||||
orderNo,
|
||||
body: `宇之然-${product.name}`,
|
||||
amount: product.price,
|
||||
clientIp: clientIp || req.ip || req.connection.remoteAddress || '127.0.0.1'
|
||||
})
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
orderNo,
|
||||
productId,
|
||||
productName: product.name,
|
||||
amount: product.price,
|
||||
h5Url: h5PayParams.h5Url
|
||||
}, 'H5支付订单创建成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('创建H5订单失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 支付回调
|
||||
* POST /api/payment/callback
|
||||
@@ -149,42 +201,48 @@ router.post('/callback', async (req, res) => {
|
||||
/**
|
||||
* 验证支付结果
|
||||
* POST /api/payment/verify
|
||||
* 只有存在该用户真实已支付订单时才解锁商品,防止未支付直接解锁
|
||||
*/
|
||||
router.post('/verify', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { productId, orderNo } = req.body
|
||||
|
||||
// 验证订单是否已处理
|
||||
if (!orderNo || !productId) {
|
||||
return ApiResponse.error(res, '缺少订单号或商品 ID', 400)
|
||||
}
|
||||
|
||||
// 校验订单是否已支付,且属于当前用户
|
||||
const order = await Order.findOne({
|
||||
orderNo,
|
||||
openid: user.openid,
|
||||
status: { $in: ['paid', 'completed'] }
|
||||
})
|
||||
|
||||
if (order) {
|
||||
return ApiResponse.success(res, {
|
||||
alreadyProcessed: true,
|
||||
message: '订单已处理'
|
||||
})
|
||||
if (!order) {
|
||||
return ApiResponse.error(res, '订单未支付,无法解锁商品', 400)
|
||||
}
|
||||
|
||||
// 校验订单对应商品
|
||||
if (order.productId !== productId) {
|
||||
return ApiResponse.error(res, '订单与商品不匹配', 400)
|
||||
}
|
||||
|
||||
// 获取商品信息
|
||||
const product = await ShopItem.findOne({ itemId: productId, status: 'active' })
|
||||
if (!product) {
|
||||
return ApiResponse.error(res, `未知商品: ${productId}`, 400)
|
||||
}
|
||||
|
||||
// 更新用户商品
|
||||
let updateData = {}
|
||||
|
||||
// 解锁商品(幂等)
|
||||
let alreadyUnlocked = false
|
||||
if (product.type === 'skin') {
|
||||
if (!user.ownedSkins.includes(productId)) {
|
||||
user.ownedSkins.push(productId)
|
||||
}
|
||||
if (user.ownedSkins.includes(productId)) alreadyUnlocked = true
|
||||
else user.ownedSkins.push(productId)
|
||||
} else if (product.type === 'noad') {
|
||||
alreadyUnlocked = user.isAdFree
|
||||
user.isAdFree = true
|
||||
} else if (product.type === 'subscription') {
|
||||
alreadyUnlocked = user.isSubscriber
|
||||
const expiry = new Date()
|
||||
expiry.setDate(expiry.getDate() + (product.duration || 30))
|
||||
user.isSubscriber = true
|
||||
@@ -193,18 +251,18 @@ router.post('/verify', async (req, res) => {
|
||||
|
||||
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()
|
||||
// 订单已处理过则直接返回
|
||||
if (order.status === 'completed') {
|
||||
return ApiResponse.success(res, {
|
||||
product,
|
||||
message: alreadyUnlocked ? '权益已生效' : `${product.name} 已解锁!`
|
||||
})
|
||||
}
|
||||
|
||||
// 标记订单为已完成
|
||||
order.status = 'completed'
|
||||
order.completedAt = new Date()
|
||||
await order.save()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
product,
|
||||
@@ -224,6 +282,7 @@ router.post('/verify', async (req, res) => {
|
||||
*/
|
||||
router.post('/refund', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { orderNo, reason } = req.body
|
||||
|
||||
// 查询订单
|
||||
@@ -232,6 +291,11 @@ router.post('/refund', async (req, res) => {
|
||||
return ApiResponse.error(res, '订单不存在', 404)
|
||||
}
|
||||
|
||||
// 权限校验:只能退自己的订单
|
||||
if (order.openid !== user.openid) {
|
||||
return ApiResponse.error(res, '无权操作该订单', 403)
|
||||
}
|
||||
|
||||
if (order.status !== 'paid' && order.status !== 'completed') {
|
||||
return ApiResponse.error(res, '订单状态不支持退款', 400)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Trend = require('../models/Trend');
|
||||
const { authMiddleware, requirePermission } = require('../middleware/auth');
|
||||
|
||||
/**
|
||||
* 获取趋势列表
|
||||
@@ -53,7 +54,7 @@ router.get('/:id', async (req, res) => {
|
||||
* 管理员:创建趋势
|
||||
* POST /api/trend/admin
|
||||
*/
|
||||
router.post('/admin', async (req, res) => {
|
||||
router.post('/admin', authMiddleware, requirePermission('knowledge:write'), async (req, res) => {
|
||||
try {
|
||||
const trend = new Trend(req.body);
|
||||
await trend.save();
|
||||
@@ -67,7 +68,7 @@ router.post('/admin', async (req, res) => {
|
||||
* 管理员:批量创建趋势(给 cron 用)
|
||||
* POST /api/trend/admin/batch
|
||||
*/
|
||||
router.post('/admin/batch', async (req, res) => {
|
||||
router.post('/admin/batch', authMiddleware, requirePermission('knowledge:write'), async (req, res) => {
|
||||
try {
|
||||
const { items } = req.body;
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { User, Order, Gallery } = require('../models')
|
||||
const { User } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
@@ -345,10 +345,6 @@ router.get('/achievements', async (req, res) => {
|
||||
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()
|
||||
@@ -365,7 +361,7 @@ router.get('/stats', async (req, res) => {
|
||||
nextLevelPoints: levelInfo.nextLevelPoints,
|
||||
totalPlayTime: playTimeHours,
|
||||
totalScore: user.totalScore || 0,
|
||||
worksCount: worksCount,
|
||||
worksCount: 0,
|
||||
collectionCount: user.collectedKnowledge?.length || 0,
|
||||
achievementCount: user.achievements?.length || 0,
|
||||
unlockedDims: user.unlockedDims || [1]
|
||||
@@ -380,39 +376,17 @@ router.get('/stats', async (req, res) => {
|
||||
/**
|
||||
* 获取用户作品列表
|
||||
* GET /api/user/works
|
||||
* 说明:画廊(Gallery)功能已移除,统一返回空列表
|
||||
*/
|
||||
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, {
|
||||
return ApiResponse.paginated(res, [], {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total
|
||||
total: 0
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/www/server/nodejs/v24.14.0/bin/node
|
||||
#!/usr/bin/node
|
||||
/**
|
||||
* AI 趋势数据更新脚本 —— 真实数据版
|
||||
* 数据源:
|
||||
@@ -62,7 +62,7 @@ function getSource(url, from) {
|
||||
|
||||
function httpGet(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, { timeout: 12000 }, (res) => {
|
||||
https.get(url, { timeout: 20000 }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', c => data += c);
|
||||
res.on('end', () => {
|
||||
@@ -75,7 +75,12 @@ function httpGet(url) {
|
||||
|
||||
/** 从 Hacker News 获取 AI 相关热门(精确匹配) */
|
||||
async function fetchHN() {
|
||||
const ids = await httpGet('https://hacker-news.firebaseio.com/v0/topstories.json');
|
||||
let ids;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try { ids = await httpGet('https://hacker-news.firebaseio.com/v0/topstories.json'); break; }
|
||||
catch (e) { console.log(` HN topstories 重试 ${attempt}: ${e.message}`); await new Promise(r => setTimeout(r, 500)); }
|
||||
}
|
||||
if (!Array.isArray(ids)) throw new Error('HN topstories 获取失败');
|
||||
const batch = ids.slice(0, 120);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(id => httpGet(`https://hacker-news.firebaseio.com/v0/item/${id}.json`))
|
||||
@@ -121,6 +126,7 @@ async function fetchArxiv() {
|
||||
const date = new Date(pub);
|
||||
return {
|
||||
title, summary: summary || title,
|
||||
body: summary, // arXiv 已经有完整摘要,直接作为正文
|
||||
source: 'arXiv', sourceUrl: link,
|
||||
category: '论文', tags: [], hot: date.getTime() > Date.now() - 2 * 86400000,
|
||||
newsDate: date, status: 'published'
|
||||
@@ -133,89 +139,112 @@ function hasChinese(text) {
|
||||
return /[\u4e00-\u9fff]/.test(text);
|
||||
}
|
||||
|
||||
/** 调用 AI 模型批量翻译英文标题和摘要到中文 */
|
||||
// 合规底线:命中以下关键词的内容不发布(可在后续按需扩充)
|
||||
const COMPLIANCE_BLOCKLIST = ['色情', '赌博', '博彩', '毒品', '暴力恐怖', '涉政敏感', '代开发票', '办证'];
|
||||
function isCompliant(item) {
|
||||
const text = [item.title, item.summary, item.body].filter(Boolean).join(' ');
|
||||
return !COMPLIANCE_BLOCKLIST.some(w => text.includes(w));
|
||||
}
|
||||
|
||||
// AI 模型统一从数据库 AIModel 集合读取(与线上 AI 问答同款,优先 SenseNova),避免硬编码失效密钥
|
||||
let _models = null;
|
||||
async function loadModels() {
|
||||
if (_models) return _models;
|
||||
try {
|
||||
const AIModel = require('../models/AIModel');
|
||||
_models = await AIModel.getActiveModels();
|
||||
} catch (e) {
|
||||
console.log(' ⚠ 读取 AIModel 失败:', e.message);
|
||||
_models = [];
|
||||
}
|
||||
return _models;
|
||||
}
|
||||
|
||||
/** 调用 AI(60s 超时 + 单次重试;自动在主备模型间切换;兼容 content / reasoning_content / reasoning) */
|
||||
async function callAI(messages, maxTokens = 1024) {
|
||||
const models = await loadModels();
|
||||
if (!models.length) { console.log(' ⚠ 无可用 AI 模型,跳过本次 AI 处理'); return ''; }
|
||||
let lastErr = null;
|
||||
for (const m of models) {
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
try {
|
||||
const signal = AbortSignal.timeout(60000);
|
||||
const res = await fetch(m.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + m.apiKey },
|
||||
body: JSON.stringify({ model: m.modelId, messages, temperature: 0.1, max_tokens: maxTokens }),
|
||||
signal
|
||||
});
|
||||
const data = await res.json();
|
||||
const msg = data?.choices?.[0]?.message || {};
|
||||
const text = (msg.content || msg.reasoning_content || msg.reasoning || '').trim();
|
||||
if (text) return text;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
console.log(` ⚠ 模型 ${m.modelId} 第${attempt}次失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastErr) console.log(' ⚠ 所有模型调用失败:', lastErr.message);
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 翻译英文内容到中文 */
|
||||
async function translateItems(items) {
|
||||
const needTranslate = items.filter(i => !hasChinese(i.title));
|
||||
if (needTranslate.length === 0) return items;
|
||||
|
||||
const apiUrl = process.env.OPENAI_API_URL || 'https://opencode.ai/zen/v1/chat/completions';
|
||||
const apiKey = ''; // OpenCode Zen 无需 API Key
|
||||
const model = process.env.OPENAI_MODEL || 'deepseek-v4-flash-free';
|
||||
|
||||
console.log(` → 翻译 ${needTranslate.length} 条英文内容...`);
|
||||
const BATCH = 1;
|
||||
|
||||
// 分批翻译,每次最多 4 条
|
||||
const BATCH = 4;
|
||||
for (let i = 0; i < needTranslate.length; i += BATCH) {
|
||||
const batch = needTranslate.slice(i, i + BATCH);
|
||||
const input = batch.map((item, idx) => {
|
||||
return `[${idx + 1}] Title: ${item.title}\nSummary: ${item.summary}`;
|
||||
}).join('\n\n');
|
||||
|
||||
const prompt = `You are an AI news translator. Translate the following AI-related news headlines to Chinese.
|
||||
Rules:
|
||||
- Keep technical terms/model names/company names in English (e.g., GPT-4, arXiv, NVIDIA, GitHub, Qwen, Grok)
|
||||
- Keep proper nouns like "Show HN:", "Mindwalk" untranslated
|
||||
- Output ONLY a JSON array in this exact format, no other text:
|
||||
[
|
||||
{"title": "translated title 1", "summary": "translated summary 1"},
|
||||
{"title": "translated title 2", "summary": "translated summary 2"}
|
||||
]
|
||||
|
||||
News to translate:
|
||||
${input}`;
|
||||
const input = batch.map((item, idx) =>
|
||||
`[${idx + 1}] Title: ${item.title}\nSummary: ${(item.summary || '').slice(0, 250)}`
|
||||
).join('\n\n');
|
||||
|
||||
try {
|
||||
const result = await httpPost(apiUrl, {
|
||||
model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
temperature: 0.1,
|
||||
max_tokens: 2048
|
||||
}, apiKey);
|
||||
const text = await callAI([{
|
||||
role: 'user',
|
||||
content: `Translate AI news to Chinese. Keep technical terms in English. Output ONLY this JSON array:\n[\n {"title":"chinese title","summary":"chinese summary"}\n]\n\nNews:\n${input}`
|
||||
}], 400);
|
||||
|
||||
const text = result?.choices?.[0]?.message?.content || '';
|
||||
console.log(` ↳ 翻译响应(前100): ${text.slice(0, 100)}`);
|
||||
// 尝试提取 JSON - 用贪婪匹配(] 只在数组末尾出现)
|
||||
const jsonMatch = text.match(/\[[\s\S]*\]/);
|
||||
if (jsonMatch) {
|
||||
// 尝试修复常见 JSON 截断问题
|
||||
let jsonStr = jsonMatch[0];
|
||||
try {
|
||||
const translations = JSON.parse(jsonStr);
|
||||
const translations = JSON.parse(jsonMatch[0]);
|
||||
if (Array.isArray(translations)) {
|
||||
translations.forEach((t, idx) => {
|
||||
if (idx < batch.length && t.title) {
|
||||
if (idx < batch.length && t.title && !hasChinese(batch[idx].title)) {
|
||||
batch[idx].title = t.title;
|
||||
if (t.summary) batch[idx].summary = t.summary;
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
} catch (parseErr) {
|
||||
// 截断了,尝试逐行模式:按标题-摘要配对解析
|
||||
console.log(` ↳ JSON 截断,尝试文本解析...`);
|
||||
// 从响应中提取所有 title: 和 summary: 字段
|
||||
const titles = [...jsonStr.matchAll(/"title"\s*:\s*"((?:[^"\\]|\\.)*)"/g)].map(m => m[1]);
|
||||
const summaries = [...jsonStr.matchAll(/"summary"\s*:\s*"((?:[^"\\]|\\.)*)"/g)].map(m => m[1]);
|
||||
titles.forEach((t, idx) => {
|
||||
if (idx < batch.length && !hasChinese(batch[idx].title)) {
|
||||
batch[idx].title = t;
|
||||
if (summaries[idx]) batch[idx].summary = summaries[idx];
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log(` ⚠ 批次 ${i / BATCH + 1} 解析失败: ${text.slice(0, 100)}`);
|
||||
} catch (_) {}
|
||||
// JSON 截断,尝试正则提取
|
||||
const titles = [...jsonMatch[0].matchAll(/"title"\s*:\s*"((?:[^"\\]|\\.)*)"/g)].map(m => m[1]);
|
||||
titles.forEach((t, idx) => {
|
||||
if (idx < batch.length && !hasChinese(batch[idx].title)) batch[idx].title = t;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` ⚠ 翻译批次 ${i / BATCH + 1} 失败: ${e.message}`);
|
||||
}
|
||||
|
||||
// 等一秒避免限流
|
||||
if (i + BATCH < needTranslate.length) {
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
if (i + BATCH < needTranslate.length) await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 为趋势生成正文内容:直接用已翻译的中文 summary 作为正文(避免长文本生成超时,保证稳定产出中文) */
|
||||
async function enrichBody(items) {
|
||||
const needBody = items.filter(i => !i.body);
|
||||
if (needBody.length === 0) return items;
|
||||
console.log(` → 正文使用中文摘要填充: ${needBody.length} 条`);
|
||||
needBody.forEach(item => {
|
||||
item.body = item.summary || item.title;
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -287,22 +316,34 @@ async function main() {
|
||||
// 合并数据
|
||||
let items = [...hn, ...arxiv];
|
||||
|
||||
// 如果不够,补保底
|
||||
if (items.length < 6) {
|
||||
const need = Math.min(FALLBACK.length, MAX_ITEMS - items.length);
|
||||
const fallback = FALLBACK.slice(0, need).map(f => ({ ...f, newsDate: f.newsDate() }));
|
||||
items.push(...fallback);
|
||||
console.log(` ✓ 保底: ${need} 条`);
|
||||
}
|
||||
|
||||
// 翻译英文内容为中文
|
||||
if (items.length > 0) {
|
||||
items = await translateItems(items);
|
||||
}
|
||||
|
||||
// 清理旧数据
|
||||
const cutoff = new Date(Date.now() - MAX_AGE_DAYS * 86400000);
|
||||
const deleted = await Trend.deleteMany({ newsDate: { $lt: cutoff } });
|
||||
// 生成正文内容(body 字段,中文)
|
||||
if (items.length > 0) {
|
||||
items = await enrichBody(items);
|
||||
}
|
||||
|
||||
// 合规过滤:命中底线的内容不发布
|
||||
items = items.filter(isCompliant);
|
||||
console.log(` → 合规过滤后: ${items.length} 条`);
|
||||
|
||||
// 默认中文:丢弃翻译/生成后仍非中文的条目,保证 feed 全中文
|
||||
items = items.filter(i => hasChinese(i.title));
|
||||
console.log(` → 仅保留中文: ${items.length} 条`);
|
||||
|
||||
// 数量不足时用中文保底数据补充
|
||||
if (items.length < 8) {
|
||||
const need = Math.min(FALLBACK.length, MAX_ITEMS - items.length);
|
||||
const fallback = FALLBACK.slice(0, need).map(f => ({ ...f, newsDate: f.newsDate() }));
|
||||
items.push(...fallback);
|
||||
console.log(` ✓ 中文保底补充: ${need} 条`);
|
||||
}
|
||||
|
||||
// 全量刷新:清空旧数据(避免残留英文 / 测试数据)
|
||||
await Trend.deleteMany({});
|
||||
|
||||
// 去重插入
|
||||
let inserted = 0;
|
||||
@@ -331,11 +372,10 @@ async function main() {
|
||||
console.log(` HN=${hn.length} arXiv=${arxiv.length} 新增=${inserted} 数据库共=${finalCount} 条`);
|
||||
|
||||
if (items.length > 0) {
|
||||
console.log('\n📰 最新趋势:');
|
||||
console.log('\n📰 最新趋势(中文):');
|
||||
items.slice(0, Math.min(items.length, 10)).forEach((t, i) => {
|
||||
const tag = t.hot ? '🔥' : t.picked ? '📌' : ' ';
|
||||
const cat = t.category?.padEnd(4) || '';
|
||||
const date = t.newsDate ? new Date(t.newsDate).toLocaleDateString('zh-CN') : '';
|
||||
const cat = (t.category || '').padEnd(4);
|
||||
console.log(` ${tag} [${cat}] ${t.title} (${t.source})`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ class WeChatService {
|
||||
out_trade_no: orderNo,
|
||||
notify_url: this.notifyUrl,
|
||||
amount: {
|
||||
total: Math.round(amount * 100), // 转换为分
|
||||
total: Math.round(amount), // 金额单位已为分
|
||||
currency: 'CNY'
|
||||
},
|
||||
payer: {
|
||||
@@ -184,6 +184,56 @@ class WeChatService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建H5支付订单(微信H5支付/MWEB)
|
||||
* @param {Object} orderData - { orderNo, body, amount, clientIp, appId }
|
||||
*/
|
||||
async createH5PaymentOrder(orderData) {
|
||||
const { orderNo, body, amount, clientIp, appId } = orderData
|
||||
|
||||
const nonceStr = this.generateNonceStr()
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||
|
||||
// 1. 统一下单 (H5 / MWEB)
|
||||
const url = '/v3/pay/transactions/h5'
|
||||
const fullUrl = `https://api.mch.weixin.qq.com${url}`
|
||||
|
||||
const orderParams = {
|
||||
appid: appId || this.appId,
|
||||
mchid: this.mchId,
|
||||
description: body,
|
||||
out_trade_no: orderNo,
|
||||
notify_url: this.notifyUrl,
|
||||
amount: {
|
||||
total: Math.round(amount), // 金额单位已为分
|
||||
currency: 'CNY'
|
||||
},
|
||||
scene_info: {
|
||||
h5_info: {
|
||||
type: 'Wap',
|
||||
client_ip: clientIp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟返回 h5_url (生产环境需调用真实微信支付接口)
|
||||
// 实际生产调用示例:
|
||||
// const sign = this.generateSign('POST', url, timestamp, nonceStr, JSON.stringify(orderParams))
|
||||
// const response = await axios.post(fullUrl, orderParams, {
|
||||
// headers: { 'Authorization': `WECHATPAY2-SHA256-RSA2048 ${sign}` }
|
||||
// })
|
||||
// const h5Url = response.data.h5_url
|
||||
|
||||
// 模拟 H5 支付链接(生产环境由微信返回真实 MWEB_URL)
|
||||
const mockH5Url = `https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx_mock_${Date.now()}&package=mock_${Date.now()}`
|
||||
|
||||
return {
|
||||
h5Url: mockH5Url,
|
||||
orderNo,
|
||||
amount: Math.round(amount)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请退款
|
||||
*/
|
||||
|
||||
@@ -11,15 +11,21 @@ const Admin = require('../../src/models/Admin')
|
||||
describe('Auth API', () => {
|
||||
beforeAll(async () => {
|
||||
// 连接测试数据库
|
||||
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/wdkj'
|
||||
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/wdkj_test'
|
||||
if (mongoose.connection.readyState === 0) {
|
||||
await mongoose.connect(mongoURI)
|
||||
}
|
||||
// 确保唯一索引已构建(dropDatabase 后 mongoose 不会自动重建)
|
||||
await mongoose.connection.syncIndexes()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
// 清理并关闭连接
|
||||
if (mongoose.connection.readyState !== 0) {
|
||||
// 防呆:只允许在测试库中 dropDatabase
|
||||
if (mongoose.connection.name === 'wdkj') {
|
||||
throw new Error('[安全] 不允许对生产数据库 wdkj 执行 dropDatabase')
|
||||
}
|
||||
await mongoose.connection.dropDatabase()
|
||||
await mongoose.connection.close()
|
||||
}
|
||||
|
||||
@@ -1,205 +1,170 @@
|
||||
/**
|
||||
* 用户 API 集成测试
|
||||
*/
|
||||
|
||||
const request = require('supertest')
|
||||
const mongoose = require('mongoose')
|
||||
const app = require('../../src/index')
|
||||
const User = require('../../src/models/User')
|
||||
const jwt = require('jsonwebtoken')
|
||||
|
||||
describe('User API', () => {
|
||||
let testUser
|
||||
let authToken
|
||||
|
||||
beforeAll(async () => {
|
||||
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/wdkj'
|
||||
if (mongoose.connection.readyState === 0) {
|
||||
await mongoose.connect(mongoURI)
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (mongoose.connection.readyState !== 0) {
|
||||
await mongoose.connection.dropDatabase()
|
||||
await mongoose.connection.close()
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await User.deleteMany({})
|
||||
|
||||
// 创建测试用户
|
||||
testUser = await User.create({
|
||||
openid: 'test_user_openid',
|
||||
nickName: '测试用户',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
exploreData: {
|
||||
dim1: { bestScore: 100, completed: true },
|
||||
dim2: { bestArea: 200, completed: false }
|
||||
},
|
||||
totalScore: 300,
|
||||
unlockedDims: [1, 2]
|
||||
})
|
||||
|
||||
// 生成认证 Token
|
||||
authToken = jwt.sign(
|
||||
{ userId: testUser._id.toString(), openid: testUser.openid },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h' }
|
||||
)
|
||||
})
|
||||
|
||||
describe('GET /api/user/progress', () => {
|
||||
test('应该返回用户探索进度', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/progress')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
expect(response.body.data.exploreData).toBeDefined()
|
||||
expect(response.body.data.exploreData.dim1.bestScore).toBe(100)
|
||||
expect(response.body.data.exploreData.dim2.bestArea).toBe(200)
|
||||
})
|
||||
|
||||
test('不存在的用户应该返回404', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/progress')
|
||||
.set('Authorization', 'Bearer invalid_token')
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/user/progress', () => {
|
||||
test('应该成功保存探索进度', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/user/progress')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
dim: 1,
|
||||
score: 150,
|
||||
collectedEggs: 5
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
|
||||
// 验证数据库更新
|
||||
const updatedUser = await User.findOne({ openid: 'test_user_openid' })
|
||||
expect(updatedUser.exploreData.dim1.bestScore).toBe(150)
|
||||
expect(updatedUser.exploreData.dim1.collectedEggs).toBe(5)
|
||||
})
|
||||
|
||||
test('新用户应该自动解锁维度', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/user/progress')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
dim: 3,
|
||||
score: 100
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const updatedUser = await User.findOne({ openid: 'test_user_openid' })
|
||||
expect(updatedUser.unlockedDims).toContain(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/user/leaderboard', () => {
|
||||
beforeEach(async () => {
|
||||
// 创建多个测试用户
|
||||
await User.create([
|
||||
{ openid: 'user1', nickName: '用户1', totalScore: 500, dim1Score: 200 },
|
||||
{ openid: 'user2', nickName: '用户2', totalScore: 300, dim1Score: 150 },
|
||||
{ openid: 'user3', nickName: '用户3', totalScore: 400, dim1Score: 100 }
|
||||
])
|
||||
})
|
||||
|
||||
test('应该返回总分排行榜', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/leaderboard')
|
||||
.query({ limit: 10 })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
expect(response.body.data).toHaveLength(4) // 包含testUser
|
||||
expect(response.body.data[0].totalScore).toBeGreaterThanOrEqual(
|
||||
response.body.data[1].totalScore
|
||||
)
|
||||
})
|
||||
|
||||
test('应该返回维度排行榜', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/leaderboard')
|
||||
.query({ dim: 1, limit: 10 })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.data[0].dim1Score).toBe(200)
|
||||
})
|
||||
|
||||
test('应该限制返回数量', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/leaderboard')
|
||||
.query({ limit: 2 })
|
||||
|
||||
expect(response.body.data).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/user/info', () => {
|
||||
test('应该成功更新用户信息', async () => {
|
||||
const response = await request(app)
|
||||
.put('/api/user/info')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
nickName: '新名字',
|
||||
avatarUrl: 'https://example.com/new-avatar.jpg'
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
|
||||
const updatedUser = await User.findOne({ openid: 'test_user_openid' })
|
||||
expect(updatedUser.nickName).toBe('新名字')
|
||||
expect(updatedUser.avatarUrl).toBe('https://example.com/new-avatar.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/user/collect-knowledge', () => {
|
||||
test('应该成功收藏知识', async () => {
|
||||
const knowledgeId = new mongoose.Types.ObjectId()
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/user/collect-knowledge')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ knowledgeId: knowledgeId.toString() })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
|
||||
const updatedUser = await User.findOne({ openid: 'test_user_openid' })
|
||||
expect(updatedUser.collectedKnowledge).toContainEqual(knowledgeId)
|
||||
})
|
||||
|
||||
test('重复收藏应该返回已存在', async () => {
|
||||
const knowledgeId = new mongoose.Types.ObjectId()
|
||||
|
||||
// 第一次收藏
|
||||
await request(app)
|
||||
.post('/api/user/collect-knowledge')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ knowledgeId: knowledgeId.toString() })
|
||||
|
||||
// 第二次收藏
|
||||
const response = await request(app)
|
||||
.post('/api/user/collect-knowledge')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ knowledgeId: knowledgeId.toString() })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
/**
|
||||
* 用户 API 集成测试
|
||||
*/
|
||||
|
||||
const request = require('supertest')
|
||||
const mongoose = require('mongoose')
|
||||
const app = require('../../src/index')
|
||||
const User = require('../../src/models/User')
|
||||
const jwt = require('jsonwebtoken')
|
||||
|
||||
describe('User API', () => {
|
||||
let testUser
|
||||
let authToken
|
||||
|
||||
beforeAll(async () => {
|
||||
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/wdkj_test'
|
||||
if (mongoose.connection.readyState === 0) {
|
||||
await mongoose.connect(mongoURI)
|
||||
}
|
||||
// 确保唯一索引已构建(dropDatabase 后 mongoose 不会自动重建)
|
||||
await mongoose.connection.syncIndexes()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (mongoose.connection.readyState !== 0) {
|
||||
// 防呆:只允许在测试库中 dropDatabase
|
||||
if (mongoose.connection.name === 'wdkj') {
|
||||
throw new Error('[安全] 不允许对生产数据库 wdkj 执行 dropDatabase')
|
||||
}
|
||||
await mongoose.connection.dropDatabase()
|
||||
await mongoose.connection.close()
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await User.deleteMany({})
|
||||
|
||||
// 创建测试用户
|
||||
testUser = await User.create({
|
||||
openid: 'test_user_openid',
|
||||
nickName: '测试用户',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
exploreData: {
|
||||
dim1: { bestScore: 100, completed: true },
|
||||
dim2: { bestArea: 200, completed: false }
|
||||
},
|
||||
unlockedDims: [1, 2]
|
||||
})
|
||||
|
||||
// 生成认证 Token
|
||||
authToken = jwt.sign(
|
||||
{ userId: testUser._id.toString(), openid: testUser.openid },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h' }
|
||||
)
|
||||
})
|
||||
|
||||
describe('GET /api/user/progress', () => {
|
||||
test('应该返回用户探索进度', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/progress')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
expect(response.body.data.isNewUser).toBe(false)
|
||||
const userData = response.body.data.data
|
||||
expect(userData.exploreData).toBeDefined()
|
||||
expect(userData.exploreData.dim1.bestScore).toBe(100)
|
||||
expect(userData.exploreData.dim2.bestArea).toBe(200)
|
||||
})
|
||||
|
||||
test('无效 token 应该返回401', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/progress')
|
||||
.set('Authorization', 'Bearer invalid_token')
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/user/progress', () => {
|
||||
test('应该成功保存探索进度', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/user/progress')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
dim: 'dim1',
|
||||
data: { bestScore: 150 }
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
expect(response.body.data.totalScore).toBe(350)
|
||||
|
||||
// 验证数据库更新
|
||||
const updatedUser = await User.findOne({ openid: 'test_user_openid' })
|
||||
expect(updatedUser.exploreData.dim1.bestScore).toBe(150)
|
||||
})
|
||||
|
||||
test('完成维度后应解锁下一维度', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/user/progress')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
dim: 'dim2',
|
||||
data: { completed: true, bestArea: 300 }
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const updatedUser = await User.findOne({ openid: 'test_user_openid' })
|
||||
expect(updatedUser.unlockedDims).toContain(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/user/leaderboard', () => {
|
||||
beforeEach(async () => {
|
||||
// 创建多个测试用户(分数由 exploreData 派生)
|
||||
await User.create([
|
||||
{ openid: 'user1', nickName: '用户1', exploreData: { dim1: { bestScore: 500 } } },
|
||||
{ openid: 'user2', nickName: '用户2', exploreData: { dim1: { bestScore: 300 } } },
|
||||
{ openid: 'user3', nickName: '用户3', exploreData: { dim1: { bestScore: 400 } } }
|
||||
])
|
||||
})
|
||||
|
||||
test('应该返回总分排行榜', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/leaderboard')
|
||||
.query({ limit: 10 })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
expect(response.body.data).toHaveLength(4) // 包含testUser
|
||||
expect(response.body.data[0].totalScore).toBeGreaterThanOrEqual(
|
||||
response.body.data[1].totalScore
|
||||
)
|
||||
})
|
||||
|
||||
test('应该返回维度排行榜', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/leaderboard')
|
||||
.query({ dim: 1, limit: 10 })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.data[0].dim1Score).toBe(500)
|
||||
})
|
||||
|
||||
test('应该限制返回数量', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/leaderboard')
|
||||
.query({ limit: 2 })
|
||||
|
||||
expect(response.body.data).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/user/achievements', () => {
|
||||
test('应该返回成就列表', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/achievements')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
expect(Array.isArray(response.body.data)).toBe(true)
|
||||
expect(response.body.data.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,11 +5,28 @@
|
||||
// 增加测试超时时间
|
||||
jest.setTimeout(30000)
|
||||
|
||||
// 测试环境必须使用独立的测试数据库,绝不能操作生产库(wdkj)
|
||||
const TEST_DB_NAME = 'wdkj_test'
|
||||
const DANGER_DB_NAMES = ['wdkj']
|
||||
|
||||
function assertTestDatabase() {
|
||||
const mongoose = require('mongoose')
|
||||
const name = mongoose.connection.name
|
||||
if (!name) return
|
||||
if (DANGER_DB_NAMES.includes(name)) {
|
||||
throw new Error(
|
||||
`[安全] 测试不允许连接生产数据库 "${name}"。请通过 MONGODB_URI 指定独立的测试数据库(如 ${TEST_DB_NAME})。`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 全局清理
|
||||
afterAll(async () => {
|
||||
// 关闭所有数据库连接
|
||||
const mongoose = require('mongoose')
|
||||
if (mongoose.connection.readyState !== 0) {
|
||||
// 防呆:确认当前不是生产库后才清理/关闭
|
||||
assertTestDatabase()
|
||||
await mongoose.connection.close()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,11 +8,17 @@ const User = require('../../src/models/User')
|
||||
describe('User Model', () => {
|
||||
beforeAll(async () => {
|
||||
// 连接测试数据库
|
||||
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/wdkj'
|
||||
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/wdkj_test'
|
||||
await mongoose.connect(mongoURI)
|
||||
// 确保唯一索引已构建(dropDatabase 后 mongoose 不会自动重建)
|
||||
await mongoose.connection.syncIndexes()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
// 防呆:只允许在测试库中 dropDatabase
|
||||
if (mongoose.connection.name === 'wdkj') {
|
||||
throw new Error('[安全] 不允许对生产数据库 wdkj 执行 dropDatabase')
|
||||
}
|
||||
// 清理并关闭连接
|
||||
await mongoose.connection.dropDatabase()
|
||||
await mongoose.connection.close()
|
||||
@@ -42,12 +48,15 @@ describe('User Model', () => {
|
||||
expect(savedUser.totalScore).toBe(0)
|
||||
})
|
||||
|
||||
test('openid 是必需的', async () => {
|
||||
const user = new User({
|
||||
nickName: '测试用户'
|
||||
test('username 必须唯一', async () => {
|
||||
await new User({ username: 'alice', nickName: '用户A' }).save()
|
||||
|
||||
const duplicateUser = new User({
|
||||
username: 'alice',
|
||||
nickName: '用户B'
|
||||
})
|
||||
|
||||
await expect(user.save()).rejects.toThrow()
|
||||
await expect(duplicateUser.save()).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('openid 必须唯一', async () => {
|
||||
@@ -134,11 +143,11 @@ describe('User Model', () => {
|
||||
|
||||
describe('静态方法', () => {
|
||||
test('getLeaderboard 应该返回排行榜', async () => {
|
||||
// 创建测试用户
|
||||
// 创建测试用户(分数由 exploreData 派生,保存时自动计算)
|
||||
await User.create([
|
||||
{ openid: 'user1', totalScore: 300, dim1Score: 100 },
|
||||
{ openid: 'user2', totalScore: 500, dim1Score: 200 },
|
||||
{ openid: 'user3', totalScore: 400, dim1Score: 150 }
|
||||
{ openid: 'user1', exploreData: { dim1: { bestScore: 300 } } },
|
||||
{ openid: 'user2', exploreData: { dim1: { bestScore: 500 } } },
|
||||
{ openid: 'user3', exploreData: { dim1: { bestScore: 400 } } }
|
||||
])
|
||||
|
||||
const leaderboard = await User.getLeaderboard(null, 10)
|
||||
@@ -151,9 +160,9 @@ describe('User Model', () => {
|
||||
|
||||
test('getLeaderboard 应该支持按维度排序', async () => {
|
||||
await User.create([
|
||||
{ openid: 'user1', dim1Score: 100, totalScore: 100 },
|
||||
{ openid: 'user2', dim1Score: 200, totalScore: 200 },
|
||||
{ openid: 'user3', dim1Score: 150, totalScore: 150 }
|
||||
{ openid: 'user1', exploreData: { dim1: { bestScore: 100 } } },
|
||||
{ openid: 'user2', exploreData: { dim1: { bestScore: 200 } } },
|
||||
{ openid: 'user3', exploreData: { dim1: { bestScore: 150 } } }
|
||||
])
|
||||
|
||||
const leaderboard = await User.getLeaderboard(1, 10)
|
||||
|
||||
Reference in New Issue
Block a user