diff --git a/admin/src/App.vue b/admin/src/App.vue index 35ce13f..4ef8cae 100644 --- a/admin/src/App.vue +++ b/admin/src/App.vue @@ -30,6 +30,7 @@ import Trends from './views/Trends.vue' import Quotas from './views/Quotas.vue' import Feedbacks from './views/Feedbacks.vue' import Logs from './views/Logs.vue' +import VpayOrders from './views/VpayOrders.vue' const token = ref(getToken()) const view = ref('dashboard') @@ -42,6 +43,7 @@ const menus = [ { key: 'trends', label: '趋势管理', icon: '📈', comp: Trends }, { key: 'quotas', label: '问答额度', icon: '💬', comp: Quotas }, { key: 'feedbacks', label: '反馈管理', icon: '📝', comp: Feedbacks }, + { key: 'vpay', label: '订单/余额', icon: '💰', comp: VpayOrders }, { key: 'logs', label: '访问日志', icon: '🗂️', comp: Logs }, ] diff --git a/admin/src/views/VpayOrders.vue b/admin/src/views/VpayOrders.vue new file mode 100644 index 0000000..14a0a37 --- /dev/null +++ b/admin/src/views/VpayOrders.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/client/src/pages.json b/client/src/pages.json index f17c28d..7279d30 100644 --- a/client/src/pages.json +++ b/client/src/pages.json @@ -130,6 +130,12 @@ "style": { "navigationBarTitleText": "充值星尘" } + }, + { + "path": "pages/vpay/ledger", + "style": { + "navigationBarTitleText": "星尘明细" + } } ], "globalStyle": { diff --git a/client/src/pages/profile/profile.vue b/client/src/pages/profile/profile.vue index 44808c4..eb5e865 100644 --- a/client/src/pages/profile/profile.vue +++ b/client/src/pages/profile/profile.vue @@ -51,8 +51,9 @@ 今日免费问答剩余 {{ freeRemaining }} 次 - - 充值 + + 充值 + 明细 @@ -238,6 +239,7 @@ async function handleLogout() { function goLogin() { uni.navigateTo({ url: '/pages/login/login' }) } function goHome() { uni.switchTab({ url: '/pages/index/index' }) } +function goLedger() { uni.navigateTo({ url: '/pages/vpay/ledger' }) } function goRecharge() { uni.navigateTo({ url: '/pages/vpay/recharge' }) } function goChat() { uni.switchTab({ url: '/pages/chat/chat' }) } function goFavorites() { uni.navigateTo({ url: '/pages/favorites/favorites' }) } @@ -646,3 +648,8 @@ onShareTimeline(() => ({ .stats-hint { text-align: center; padding: 4px 0 8px; } .stats-hint text { font-size: 11px; color: var(--text-muted); } + + diff --git a/client/src/pages/vpay/ledger.vue b/client/src/pages/vpay/ledger.vue new file mode 100644 index 0000000..6b8c589 --- /dev/null +++ b/client/src/pages/vpay/ledger.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/server/src/models/CoinLedger.js b/server/src/models/CoinLedger.js new file mode 100644 index 0000000..0fdcd9b --- /dev/null +++ b/server/src/models/CoinLedger.js @@ -0,0 +1,19 @@ +const mongoose = require('mongoose') + +/** + * 星尘余额流水 —— 每次充值/消费/退款/管理员调整都记一条 + */ +const CoinLedgerSchema = new mongoose.Schema({ + openid: { type: String, required: true, index: true }, + userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', index: true }, + // recharge=充值 | consume=消费 | refund=退款 | adjust=管理员调整 | gift=赠送 + type: { type: String, enum: ['recharge', 'consume', 'refund', 'adjust', 'gift'], required: true }, + amount: { type: Number, required: true }, // 正=入账 负=扣减 + balanceAfter: { type: Number, required: true }, // 操作后余额(对账用) + description: { type: String, default: '' }, + orderNo: { type: String, default: '', index: true }, + adminName: { type: String, default: '' }, // adjust 时记录操作管理员 + createdAt: { type: Date, default: Date.now, index: true } +}) + +module.exports = mongoose.model('CoinLedger', CoinLedgerSchema) diff --git a/server/src/models/index.js b/server/src/models/index.js index fd01ec6..4122ce9 100755 --- a/server/src/models/index.js +++ b/server/src/models/index.js @@ -1,29 +1,31 @@ -const User = require('./User') -const Order = require('./Order') -const Knowledge = require('./Knowledge') -const KnowledgeLit = require('./KnowledgeLit') -const ShopItem = require('./ShopItem') -const Admin = require('./Admin') -const AdminLog = require('./AdminLog') -const AIChatQuota = require('./AIChatQuota') -const AIModel = require('./AIModel') -const LoginLog = require('./LoginLog') -const Feedback = require('./Feedback') -const Trend = require('./Trend') -const VerificationCode = require('./VerificationCode') - -module.exports = { - User, - Order, - Knowledge, - KnowledgeLit, - ShopItem, - Admin, - AdminLog, - AIChatQuota, - AIModel, - LoginLog, - Feedback, - Trend, - VerificationCode -} +const User = require('./User') +const Order = require('./Order') +const Knowledge = require('./Knowledge') +const KnowledgeLit = require('./KnowledgeLit') +const ShopItem = require('./ShopItem') +const Admin = require('./Admin') +const AdminLog = require('./AdminLog') +const AIChatQuota = require('./AIChatQuota') +const AIModel = require('./AIModel') +const LoginLog = require('./LoginLog') +const Feedback = require('./Feedback') +const Trend = require('./Trend') +const VerificationCode = require('./VerificationCode') +const CoinLedger = require('./CoinLedger') + +module.exports = { + User, + Order, + Knowledge, + KnowledgeLit, + ShopItem, + Admin, + AdminLog, + AIChatQuota, + AIModel, + LoginLog, + Feedback, + Trend, + VerificationCode, + CoinLedger +} diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index b23f288..5b5ee85 100755 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -1,7 +1,7 @@ const express = require('express') const router = express.Router() const mongoose = require('mongoose') -const { User, Order, Knowledge, ShopItem, AdminLog, AIChatQuota, Feedback, LoginLog, Trend, KnowledgeLit } = require('../models') +const { User, Order, Knowledge, ShopItem, AdminLog, AIChatQuota, Feedback, LoginLog, Trend, KnowledgeLit, CoinLedger } = require('../models') const { authMiddleware, requirePermission } = require('../middleware/auth') const ApiResponse = require('../utils/response') const logger = require('../utils/logger') @@ -1199,4 +1199,128 @@ router.get('/dashboard-v2', requirePermission('dashboard:read'), async (req, res } }) + +/** + * ===== 虚拟支付订单管理 ===== + */ + +// 订单列表(含虚拟支付 + 旧订单) +router.get('/vpay/orders', requirePermission('orders:read'), async (req, res) => { + try { + const { page = 1, limit = 20, status = '', keyword = '' } = req.query + const query = {} + if (status) query.status = status + if (keyword) query.orderNo = { $regex: keyword, $options: 'i' } + const total = await Order.countDocuments(query) + const items = await Order.find(query) + .sort({ createdAt: -1 }) + .skip((parseInt(page) - 1) * parseInt(limit)) + .limit(parseInt(limit)) + .populate('userId', 'nickname openid') + .lean() + return ApiResponse.paginated(res, items, { page: parseInt(page), limit: parseInt(limit), total }) + } catch (error) { + logger.error('vpay orders 失败:', error) + return ApiResponse.serverError(res, '获取订单失败') + } +}) + +// 订单退款(星尘扣回 + 状态改 refunded + 写流水) +router.post('/vpay/orders/:id/refund', requirePermission('orders:write'), async (req, res) => { + try { + const order = await Order.findById(req.params.id) + if (!order) return ApiResponse.notFound(res, '订单不存在') + if (order.status !== 'paid') return ApiResponse.error(res, '仅已支付订单可退款', 400) + + const coins = (order.metadata || {}).coins || 0 + const user = await User.findById(order.userId) + if (!user) return ApiResponse.error(res, '用户不存在', 400) + + // 扣回星尘(可能不够扣,允许负数让管理员判断) + user.vpayCoins = (user.vpayCoins || 0) - coins + await user.save() + + await Order.updateOne({ _id: order._id }, { status: 'refunded', refundedAt: new Date() }) + await CoinLedger.create({ + openid: user.openid, userId: user._id, type: 'refund', amount: -coins, + balanceAfter: user.vpayCoins, description: `退款 ${order.orderNo}`, + orderNo: order.orderNo, adminName: req.admin.username, + }) + await logAction(req, 'refund', 'vpay_order', order._id, { orderNo: order.orderNo, coins }) + return ApiResponse.success(res, { orderNo: order.orderNo, coinsDeducted: coins, balanceAfter: user.vpayCoins }, '退款成功') + } catch (error) { + logger.error('vpay refund 失败:', error) + return ApiResponse.serverError(res, '退款失败') + } +}) + +// 管理员调整余额(赠/扣) +router.post('/vpay/adjust', requirePermission('orders:write'), async (req, res) => { + try { + const { openid, amount, reason } = req.body + if (!openid || !amount || Math.abs(amount) > 100000) return ApiResponse.error(res, '参数无效', 400) + const user = await User.findOne({ openid }) + if (!user) return ApiResponse.notFound(res, '用户不存在') + + user.vpayCoins = (user.vpayCoins || 0) + amount + await user.save() + await CoinLedger.create({ + openid, userId: user._id, type: 'adjust', amount, + balanceAfter: user.vpayCoins, description: reason || '管理员调整', + adminName: req.admin.username, + }) + await logAction(req, 'adjust', 'vpay_balance', user._id, { openid, amount, reason }) + return ApiResponse.success(res, { balance: user.vpayCoins }, '调整成功') + } catch (error) { + logger.error('vpay adjust 失败:', error) + return ApiResponse.serverError(res, '调整失败') + } +}) + +// 全站星尘流水 +router.get('/vpay/ledger', requirePermission('orders:read'), async (req, res) => { + try { + const { page = 1, limit = 20, type = '', keyword = '' } = req.query + const query = {} + if (type) query.type = type + if (keyword) query.openid = { $regex: keyword, $options: 'i' } + const total = await CoinLedger.countDocuments(query) + const items = await CoinLedger.find(query) + .sort({ createdAt: -1 }) + .skip((parseInt(page) - 1) * parseInt(limit)) + .limit(parseInt(limit)) + .lean() + return ApiResponse.paginated(res, items, { page: parseInt(page), limit: parseInt(limit), total }) + } catch (error) { + logger.error('vpay ledger 失败:', error) + return ApiResponse.serverError(res, '获取流水失败') + } +}) + +// 收入统计 +router.get('/vpay/stats', requirePermission('dashboard:read'), async (req, res) => { + try { + const today = new Date(); today.setHours(0, 0, 0, 0) + const [totalRevenue, todayRevenue, totalRecharged, totalConsumed, paidCount, refundedCount] = await Promise.all([ + Order.aggregate([{ $match: { status: 'paid' } }, { $group: { _id: null, sum: { $sum: '$amount' } } }]), + Order.aggregate([{ $match: { status: 'paid', paidAt: { $gte: today } } }, { $group: { _id: null, sum: { $sum: '$amount' } } }]), + CoinLedger.aggregate([{ $match: { type: 'recharge' } }, { $group: { _id: null, sum: { $sum: '$amount' } } }]), + CoinLedger.aggregate([{ $match: { type: 'consume' } }, { $group: { _id: null, sum: { $sum: { $abs: '$amount' } } } }]), + Order.countDocuments({ status: 'paid' }), + Order.countDocuments({ status: 'refunded' }), + ]) + return ApiResponse.success(res, { + totalRevenueYuan: totalRevenue[0]?.sum || 0, + todayRevenueYuan: todayRevenue[0]?.sum || 0, + totalRechargedCoins: totalRecharged[0]?.sum || 0, + totalConsumedCoins: totalConsumed[0]?.sum || 0, + paidOrders: paidCount, + refundedOrders: refundedCount, + }) + } catch (error) { + logger.error('vpay stats 失败:', error) + return ApiResponse.serverError(res, '获取统计失败') + } +}) + module.exports = router diff --git a/server/src/routes/aiChat.js b/server/src/routes/aiChat.js index 414ca49..c915368 100755 --- a/server/src/routes/aiChat.js +++ b/server/src/routes/aiChat.js @@ -1,6 +1,6 @@ const express = require('express') const router = express.Router() -const { AIChatQuota, AIModel, User } = require('../models') +const { AIChatQuota, AIModel, User, CoinLedger } = require('../models') const ApiResponse = require('../utils/response') const { userAuthMiddleware, optionalAuthMiddleware } = require('../middleware/auth') const logger = require('../utils/logger') @@ -92,6 +92,11 @@ router.post('/ask', askRateLimit, optionalAuthMiddleware, async (req, res) => { } // 扣星尘(原子操作防并发) const updated = await User.updateOne({ _id: user._id, vpayCoins: { $gte: 10 } }, { $inc: { vpayCoins: -10 } }) + if (updated.modifiedCount > 0) { + const { CoinLedger } = require('../models') + const u3 = await User.findById(user._id, 'vpayCoins openid') + await CoinLedger.create({ openid: u3.openid, userId: user._id, type: 'consume', amount: -10, balanceAfter: u3.vpayCoins, description: 'AI问答' }) + } if (updated.modifiedCount === 0) { return ApiResponse.error(res, '星尘余额不足,请充值后继续', 402, { needRecharge: true }) } diff --git a/server/src/routes/vpay.js b/server/src/routes/vpay.js index 7df9117..a8d9f17 100644 --- a/server/src/routes/vpay.js +++ b/server/src/routes/vpay.js @@ -1,7 +1,7 @@ const express = require('express') const crypto = require('crypto') const router = express.Router() -const { User, Order, AIChatQuota } = require('../models') +const { User, Order, AIChatQuota, CoinLedger } = require('../models') const ApiResponse = require('../utils/response') const { userAuthMiddleware } = require('../middleware/auth') const logger = require('../utils/logger') @@ -82,6 +82,26 @@ router.get('/info', userAuthMiddleware, async (req, res) => { } }) +/** + * 我的星尘明细(充值/消费/退款/调整 全部记录) + * GET /api/vpay/ledger?page=1&limit=20 + */ +router.get('/ledger', userAuthMiddleware, async (req, res) => { + try { + const page = Math.max(1, parseInt(req.query.page) || 1) + const limit = Math.min(50, parseInt(req.query.limit) || 20) + const query = { openid: req.openid } + const [items, total] = await Promise.all([ + CoinLedger.find(query).sort({ createdAt: -1 }).skip((page - 1) * limit).limit(limit).lean(), + CoinLedger.countDocuments(query), + ]) + return ApiResponse.success(res, { items, total, page, limit }) + } catch (error) { + logger.error('vpay ledger 失败:', error) + return ApiResponse.serverError(res, '获取明细失败') + } +}) + /** * 创建充值订单(返回客户端拉起支付的完整参数) * POST /api/vpay/recharge { packageId } diff --git a/server/src/routes/wxmsg.js b/server/src/routes/wxmsg.js index 6579d55..fade511 100644 --- a/server/src/routes/wxmsg.js +++ b/server/src/routes/wxmsg.js @@ -51,7 +51,7 @@ module.exports = function registerWxMsgRoutes(app) { // 代币充值到账:改写用户星尘余额 + 订单状态 if (event === 'xpay_coin_pay_notify') { try { - const { User, Order } = require('../models') + const { User, Order, CoinLedger } = require('../models') const outTradeNo = fields.OutTradeNo || '' // 微信回调格式:CoinInfo.Quantity(嵌套)——兼容 JSON 和 XML 两种 const coinInfo = fields.CoinInfo || fields.coinInfo || {} @@ -90,7 +90,7 @@ module.exports = function registerWxMsgRoutes(app) { // 退款:扣回星尘 if (event === 'xpay_refund_notify') { try { - const { User, Order } = require('../models') + const { User, Order, CoinLedger } = require('../models') const outTradeNo = fields.OutTradeNo || '' const order = await Order.findOne({ orderNo: outTradeNo, status: 'paid' }) if (order) {