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:
Yuzhiran Dev
2026-09-04 11:47:29 +08:00
parent de8ca9c420
commit 66de1dc396
37 changed files with 2138 additions and 1830 deletions
+1080 -1345
View File
File diff suppressed because it is too large Load Diff
+24 -16
View File
@@ -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()
+8 -5
View File
@@ -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,
+89 -25
View File
@@ -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)
}
+3 -2
View File
@@ -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) {
+5 -31
View File
@@ -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) {