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:
+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) {
|
||||
|
||||
Reference in New Issue
Block a user