feat: 完整支付配套——CoinLedger流水模型+充值/消费/退款全链写流水+管理后台订单/余额/退款/调整/统计页+用户明细页+余额卡明细入口

This commit is contained in:
2026-09-14 12:26:41 +08:00
parent 968396bed5
commit f13309e6cd
11 changed files with 497 additions and 36 deletions
+2
View File
@@ -30,6 +30,7 @@ import Trends from './views/Trends.vue'
import Quotas from './views/Quotas.vue' import Quotas from './views/Quotas.vue'
import Feedbacks from './views/Feedbacks.vue' import Feedbacks from './views/Feedbacks.vue'
import Logs from './views/Logs.vue' import Logs from './views/Logs.vue'
import VpayOrders from './views/VpayOrders.vue'
const token = ref(getToken()) const token = ref(getToken())
const view = ref('dashboard') const view = ref('dashboard')
@@ -42,6 +43,7 @@ const menus = [
{ key: 'trends', label: '趋势管理', icon: '📈', comp: Trends }, { key: 'trends', label: '趋势管理', icon: '📈', comp: Trends },
{ key: 'quotas', label: '问答额度', icon: '💬', comp: Quotas }, { key: 'quotas', label: '问答额度', icon: '💬', comp: Quotas },
{ key: 'feedbacks', label: '反馈管理', icon: '📝', comp: Feedbacks }, { key: 'feedbacks', label: '反馈管理', icon: '📝', comp: Feedbacks },
{ key: 'vpay', label: '订单/余额', icon: '💰', comp: VpayOrders },
{ key: 'logs', label: '访问日志', icon: '🗂️', comp: Logs }, { key: 'logs', label: '访问日志', icon: '🗂️', comp: Logs },
] ]
+181
View File
@@ -0,0 +1,181 @@
<template>
<div class="page">
<div class="stats-cards">
<div class="scard"><b>¥{{ stats.totalRevenueYuan?.toFixed(2) || '0.00' }}</b><span>累计收入</span></div>
<div class="scard"><b>¥{{ stats.todayRevenueYuan?.toFixed(2) || '0.00' }}</b><span>今日收入</span></div>
<div class="scard"><b>{{ stats.totalRechargedCoins || 0 }}</b><span>累计充值星尘</span></div>
<div class="scard"><b>{{ stats.totalConsumedCoins || 0 }}</b><span>累计消费星尘</span></div>
<div class="scard"><b>{{ stats.paidOrders || 0 }}</b><span>成功订单</span><i>退款 {{ stats.refundedOrders || 0 }}</i></div>
</div>
<el-tabs v-model="tab">
<el-tab-pane label="订单管理" name="orders" />
<el-tab-pane label="星尘流水" name="ledger" />
</el-tabs>
<!-- 订单 Tab -->
<template v-if="tab === 'orders'">
<div class="toolbar">
<el-select v-model="statusFilter" style="width:120px" @change="load">
<el-option label="全部" value="" /><el-option label="已支付" value="paid" /><el-option label="待支付" value="pending" /><el-option label="已退款" value="refunded" />
</el-select>
<el-input v-model="q" placeholder="搜订单号" style="width:200px" clearable @keyup.enter="load" />
<el-button type="primary" @click="load">搜索</el-button>
<el-button type="warning" @click="adjustDlg = true">调整余额</el-button>
</div>
<el-table :data="orders" stripe v-loading="loading" size="small">
<el-table-column prop="orderNo" label="订单号" width="220" show-overflow-tooltip />
<el-table-column label="用户" width="120">
<template #default="{ row }">{{ row.userId?.nickname || (row.openid||'').slice(0,8) }}</template>
</el-table-column>
<el-table-column prop="productName" label="商品" width="120" />
<el-table-column label="金额" width="70">
<template #default="{ row }">¥{{ row.amount }}</template>
</el-table-column>
<el-table-column label="星尘" width="60">
<template #default="{ row }">{{ row.metadata?.coins || '—' }}</template>
</el-table-column>
<el-table-column label="状态" width="70">
<template #default="{ row }">
<el-tag :type="row.status === 'paid' ? 'success' : row.status === 'refunded' ? 'danger' : 'warning'" size="small">
{{ row.status === 'paid' ? '已支付' : row.status === 'refunded' ? '已退款' : row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="时间" width="150">
<template #default="{ row }">{{ fmt(row.paidAt || row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button v-if="row.status === 'paid'" size="small" type="danger" @click="refund(row)">退款</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination background layout="total, prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="load" style="margin-top:12px" />
</template>
<!-- 流水 Tab -->
<template v-else>
<div class="toolbar">
<el-select v-model="ledgerType" style="width:120px" @change="loadLedger">
<el-option label="全部" value="" /><el-option label="充值" value="recharge" /><el-option label="消费" value="consume" /><el-option label="退款" value="refund" /><el-option label="调整" value="adjust" />
</el-select>
<el-input v-model="ledgerQ" placeholder="搜openid" style="width:200px" @keyup.enter="loadLedger" />
<el-button type="primary" @click="loadLedger">搜索</el-button>
</div>
<el-table :data="ledger" stripe v-loading="ledgerLoading" size="small">
<el-table-column label="时间" width="150"><template #default="{ row }">{{ fmt(row.createdAt) }}</template></el-table-column>
<el-table-column label="用户" width="120"><template #default="{ row }">{{ (row.openid||'').slice(0,10) }}</template></el-table-column>
<el-table-column label="类型" width="60">
<template #default="{ row }">
<el-tag :type="{ recharge: 'success', consume: 'info', refund: 'danger', adjust: 'warning' }[row.type]" size="small">
{{ { recharge: '充值', consume: '消费', refund: '退款', adjust: '调整', gift: '赠送' }[row.type] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="变动" width="70">
<template #default="{ row }">
<span :style="{ color: row.amount > 0 ? '#22c55e' : '#ef4444', fontWeight: 700 }">{{ row.amount > 0 ? '+' : '' }}{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column prop="balanceAfter" label="余额" width="60" />
<el-table-column prop="description" label="说明" min-width="140" show-overflow-tooltip />
<el-table-column prop="orderNo" label="关联订单" width="200" show-overflow-tooltip />
<el-table-column prop="adminName" label="操作人" width="70" />
</el-table>
<el-pagination background layout="total, prev, pager, next" :total="ledgerTotal" :page-size="pageSize" v-model:current-page="ledgerPage" @current-change="loadLedger" style="margin-top:12px" />
</template>
<!-- 退款确认 -->
<el-dialog v-model="refundDlg" title="确认退款" width="380px">
<p>订单:{{ refundTarget?.orderNo }}</p>
<p>金额:¥{{ refundTarget?.amount }}</p>
<p>将扣回星尘:{{ refundTarget?.metadata?.coins || 0 }}</p>
<el-alert type="warning" :closable="false" title="退款后星尘将从用户余额扣除,请确认用户余额充足" style="margin-top:10px" />
<template #footer>
<el-button @click="refundDlg = false">取消</el-button>
<el-button type="danger" @click="doRefund">确认退款</el-button>
</template>
</el-dialog>
<!-- 调整余额 -->
<el-dialog v-model="adjustDlg" title="调整用户余额" width="400px">
<el-form label-width="80px">
<el-form-item label="OpenID"><el-input v-model="adjustForm.openid" placeholder="用户openid" /></el-form-item>
<el-form-item label="变动值">
<el-input-number v-model="adjustForm.amount" :min="-100000" :max="100000" />
<span style="margin-left:8px;font-size:12px;color:#999">正=赠送 负=扣减</span>
</el-form-item>
<el-form-item label="原因"><el-input v-model="adjustForm.reason" placeholder="如:活动赠送/异常补偿" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="adjustDlg = false">取消</el-button>
<el-button type="primary" @click="doAdjust">确认</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { apiGet, apiPost } from '../api'
const tab = ref('orders')
const stats = ref({})
const orders = ref([]), total = ref(0), page = ref(1), pageSize = 20, q = ref(''), statusFilter = ref(''), loading = ref(false)
const ledger = ref([]), ledgerTotal = ref(0), ledgerPage = ref(1), ledgerType = ref(''), ledgerQ = ref(''), ledgerLoading = ref(false)
const refundDlg = ref(false), refundTarget = ref(null)
const adjustDlg = ref(false), adjustForm = ref({ openid: '', amount: 100, reason: '' })
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' }
async function loadStats() {
try { stats.value = await apiGet('/api/admin/vpay/stats') } catch (e) {}
}
async function load() {
loading.value = true
try {
const r = await apiGet(`/api/admin/vpay/orders?page=${page.value}&limit=${pageSize}&status=${statusFilter.value}&keyword=${encodeURIComponent(q.value)}`)
orders.value = r.items || []
total.value = r.pagination?.total || 0
} finally { loading.value = false }
}
async function loadLedger() {
ledgerLoading.value = true
try {
const r = await apiGet(`/api/admin/vpay/ledger?page=${ledgerPage.value}&limit=${pageSize}&type=${ledgerType.value}&keyword=${encodeURIComponent(ledgerQ.value)}`)
ledger.value = r.items || []
ledgerTotal.value = r.pagination?.total || 0
} finally { ledgerLoading.value = false }
}
function refund(row) { refundTarget.value = row; refundDlg.value = true }
async function doRefund() {
try {
await ElMessageBox.confirm('退款后无法撤销,确认执行?', '最终确认', { type: 'warning' })
await apiPost(`/api/admin/vpay/orders/${refundTarget.value._id}/refund`)
ElMessage.success('退款成功')
refundDlg.value = false
load(); loadStats()
} catch (e) { if (e !== 'cancel') ElMessage.error(e.message || '退款失败') }
}
async function doAdjust() {
try {
await apiPost('/api/admin/vpay/adjust', adjustForm.value)
ElMessage.success('调整成功')
adjustDlg.value = false
loadLedger(); loadStats()
} catch (e) { ElMessage.error(e.message || '调整失败') }
}
onMounted(() => { loadStats(); load(); loadLedger() })
</script>
<style scoped>
.page { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.stats-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin-bottom: 16px; }
.scard { background: #f8f9fb; border-radius: 10px; padding: 12px; display: flex; flex-direction: column; gap: 2px; }
.scard b { font-size: 20px; color: #1f2333; }
.scard span { font-size: 12px; color: #6b7080; }
.scard i { font-size: 11px; color: #a6abbb; font-style: normal; }
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
</style>
+6
View File
@@ -130,6 +130,12 @@
"style": { "style": {
"navigationBarTitleText": "充值星尘" "navigationBarTitleText": "充值星尘"
} }
},
{
"path": "pages/vpay/ledger",
"style": {
"navigationBarTitleText": "星尘明细"
}
} }
], ],
"globalStyle": { "globalStyle": {
+9 -2
View File
@@ -51,8 +51,9 @@
</view> </view>
<text class="free-left">今日免费问答剩余 {{ freeRemaining }} 次</text> <text class="free-left">今日免费问答剩余 {{ freeRemaining }} 次</text>
</view> </view>
<view class="recharge-go" @click="goRecharge"> <view class="balance-actions">
<text>充值</text> <view class="recharge-go" @click="goRecharge"><text>充值</text></view>
<view class="recharge-go outline" @click="goLedger"><text>明细</text></view>
</view> </view>
</view> </view>
@@ -238,6 +239,7 @@ async function handleLogout() {
function goLogin() { uni.navigateTo({ url: '/pages/login/login' }) } function goLogin() { uni.navigateTo({ url: '/pages/login/login' }) }
function goHome() { uni.switchTab({ url: '/pages/index/index' }) } 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 goRecharge() { uni.navigateTo({ url: '/pages/vpay/recharge' }) }
function goChat() { uni.switchTab({ url: '/pages/chat/chat' }) } function goChat() { uni.switchTab({ url: '/pages/chat/chat' }) }
function goFavorites() { uni.navigateTo({ url: '/pages/favorites/favorites' }) } 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-align: center; padding: 4px 0 8px; }
.stats-hint text { font-size: 11px; color: var(--text-muted); } .stats-hint text { font-size: 11px; color: var(--text-muted); }
</style> </style>
<style scoped>
.balance-actions { display: flex; gap: 8px; }
.recharge-go.outline { background: transparent; border: 1.5px solid rgba(255,255,255,.6); color: #fff; }
</style>
+95
View File
@@ -0,0 +1,95 @@
<template>
<view class="ledger-page">
<view class="status-bar-placeholder"></view>
<view class="balance-header">
<text class="balance-num">{{ balance }}</text>
<text class="balance-label">星尘余额</text>
</view>
<view class="filter-row">
<view class="filter-tag" :class="{ active: filter === '' }" @tap="filter = ''; load()">全部</view>
<view class="filter-tag" :class="{ active: filter === 'recharge' }" @tap="filter = 'recharge'; load()">充值</view>
<view class="filter-tag" :class="{ active: filter === 'consume' }" @tap="filter = 'consume'; load()">消费</view>
</view>
<view v-for="item in items" :key="item._id" class="ledger-item">
<view class="li-left">
<text class="li-desc">{{ item.description || typeLabel(item.type) }}</text>
<text class="li-time">{{ fmt(item.createdAt) }}</text>
</view>
<text class="li-amount" :class="{ in: item.amount > 0, out: item.amount < 0 }">
{{ item.amount > 0 ? '+' : '' }}{{ item.amount }}
</text>
</view>
<view v-if="!loading && !items.length" class="empty">
<text>暂无记录</text>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { request } from '../../utils/api'
import { useUserStore } from '../../stores/user'
const userStore = useUserStore()
const items = ref([])
const balance = ref(0)
const filter = ref('')
const loading = ref(false)
const TYPE_LABELS = { recharge: '充值', consume: '消费', refund: '退款', adjust: '调整', gift: '赠送' }
function typeLabel(t) { return TYPE_LABELS[t] || t }
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '' }
async function load() {
loading.value = true
try {
const data = await request('GET', `/api/vpay/ledger?limit=50`, null, userStore.token)
items.value = (data.items || []).filter(i => !filter.value || i.type === filter.value)
} catch (e) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally { loading.value = false }
}
onMounted(async () => {
if (!userStore.isLoggedIn) {
uni.showToast({ title: '请先登录', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
return
}
load()
try {
const info = await request('GET', '/api/vpay/info', null, userStore.token)
balance.value = info.balance || 0
} catch (e) {}
})
</script>
<style scoped>
.ledger-page { min-height: 100vh; padding: 0 20px 30px; background: var(--bg-primary); }
.status-bar-placeholder { height: 44px; }
.balance-header { text-align: center; padding: 20px 0; }
.balance-num { font-size: 36px; font-weight: 800; color: var(--text-primary); display: block; }
.balance-label { font-size: 12px; color: var(--text-tertiary); }
.filter-row { display: flex; gap: 8px; margin-bottom: 12px; }
.filter-tag {
padding: 6px 16px; border-radius: 999px; font-size: 12px;
background: var(--glass-bg); border: 1px solid var(--glass-border); color: var(--text-secondary);
}
.filter-tag.active { background: var(--color-brand); color: #fff; border-color: var(--color-brand); }
.ledger-item {
display: flex; align-items: center; justify-content: space-between;
background: var(--glass-bg); border: 1px solid var(--glass-border);
border-radius: var(--radius-md); padding: 12px 16px; margin-bottom: 8px;
}
.li-left { flex: 1; }
.li-desc { font-size: 14px; color: var(--text-primary); display: block; }
.li-time { font-size: 11px; color: var(--text-muted); }
.li-amount { font-size: 16px; font-weight: 700; }
.li-amount.in { color: #22c55e; }
.li-amount.out { color: #ef4444; }
.empty { text-align: center; padding: 40px; color: var(--text-muted); font-size: 13px; }
</style>
+19
View File
@@ -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)
+31 -29
View File
@@ -1,29 +1,31 @@
const User = require('./User') const User = require('./User')
const Order = require('./Order') const Order = require('./Order')
const Knowledge = require('./Knowledge') const Knowledge = require('./Knowledge')
const KnowledgeLit = require('./KnowledgeLit') const KnowledgeLit = require('./KnowledgeLit')
const ShopItem = require('./ShopItem') const ShopItem = require('./ShopItem')
const Admin = require('./Admin') const Admin = require('./Admin')
const AdminLog = require('./AdminLog') const AdminLog = require('./AdminLog')
const AIChatQuota = require('./AIChatQuota') const AIChatQuota = require('./AIChatQuota')
const AIModel = require('./AIModel') const AIModel = require('./AIModel')
const LoginLog = require('./LoginLog') const LoginLog = require('./LoginLog')
const Feedback = require('./Feedback') const Feedback = require('./Feedback')
const Trend = require('./Trend') const Trend = require('./Trend')
const VerificationCode = require('./VerificationCode') const VerificationCode = require('./VerificationCode')
const CoinLedger = require('./CoinLedger')
module.exports = {
User, module.exports = {
Order, User,
Knowledge, Order,
KnowledgeLit, Knowledge,
ShopItem, KnowledgeLit,
Admin, ShopItem,
AdminLog, Admin,
AIChatQuota, AdminLog,
AIModel, AIChatQuota,
LoginLog, AIModel,
Feedback, LoginLog,
Trend, Feedback,
VerificationCode Trend,
} VerificationCode,
CoinLedger
}
+125 -1
View File
@@ -1,7 +1,7 @@
const express = require('express') const express = require('express')
const router = express.Router() const router = express.Router()
const mongoose = require('mongoose') 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 { authMiddleware, requirePermission } = require('../middleware/auth')
const ApiResponse = require('../utils/response') const ApiResponse = require('../utils/response')
const logger = require('../utils/logger') 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 module.exports = router
+6 -1
View File
@@ -1,6 +1,6 @@
const express = require('express') const express = require('express')
const router = express.Router() const router = express.Router()
const { AIChatQuota, AIModel, User } = require('../models') const { AIChatQuota, AIModel, User, CoinLedger } = require('../models')
const ApiResponse = require('../utils/response') const ApiResponse = require('../utils/response')
const { userAuthMiddleware, optionalAuthMiddleware } = require('../middleware/auth') const { userAuthMiddleware, optionalAuthMiddleware } = require('../middleware/auth')
const logger = require('../utils/logger') 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 } }) 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) { if (updated.modifiedCount === 0) {
return ApiResponse.error(res, '星尘余额不足,请充值后继续', 402, { needRecharge: true }) return ApiResponse.error(res, '星尘余额不足,请充值后继续', 402, { needRecharge: true })
} }
+21 -1
View File
@@ -1,7 +1,7 @@
const express = require('express') const express = require('express')
const crypto = require('crypto') const crypto = require('crypto')
const router = express.Router() const router = express.Router()
const { User, Order, AIChatQuota } = require('../models') const { User, Order, AIChatQuota, CoinLedger } = require('../models')
const ApiResponse = require('../utils/response') const ApiResponse = require('../utils/response')
const { userAuthMiddleware } = require('../middleware/auth') const { userAuthMiddleware } = require('../middleware/auth')
const logger = require('../utils/logger') 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 } * POST /api/vpay/recharge { packageId }
+2 -2
View File
@@ -51,7 +51,7 @@ module.exports = function registerWxMsgRoutes(app) {
// 代币充值到账:改写用户星尘余额 + 订单状态 // 代币充值到账:改写用户星尘余额 + 订单状态
if (event === 'xpay_coin_pay_notify') { if (event === 'xpay_coin_pay_notify') {
try { try {
const { User, Order } = require('../models') const { User, Order, CoinLedger } = require('../models')
const outTradeNo = fields.OutTradeNo || '' const outTradeNo = fields.OutTradeNo || ''
// 微信回调格式:CoinInfo.Quantity(嵌套)——兼容 JSON 和 XML 两种 // 微信回调格式:CoinInfo.Quantity(嵌套)——兼容 JSON 和 XML 两种
const coinInfo = fields.CoinInfo || fields.coinInfo || {} const coinInfo = fields.CoinInfo || fields.coinInfo || {}
@@ -90,7 +90,7 @@ module.exports = function registerWxMsgRoutes(app) {
// 退款:扣回星尘 // 退款:扣回星尘
if (event === 'xpay_refund_notify') { if (event === 'xpay_refund_notify') {
try { try {
const { User, Order } = require('../models') const { User, Order, CoinLedger } = require('../models')
const outTradeNo = fields.OutTradeNo || '' const outTradeNo = fields.OutTradeNo || ''
const order = await Order.findOne({ orderNo: outTradeNo, status: 'paid' }) const order = await Order.findOne({ orderNo: outTradeNo, status: 'paid' })
if (order) { if (order) {