feat: 完整支付配套——CoinLedger流水模型+充值/消费/退款全链写流水+管理后台订单/余额/退款/调整/统计页+用户明细页+余额卡明细入口
This commit is contained in:
@@ -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 },
|
||||
]
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -130,6 +130,12 @@
|
||||
"style": {
|
||||
"navigationBarTitleText": "充值星尘"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/vpay/ledger",
|
||||
"style": {
|
||||
"navigationBarTitleText": "星尘明细"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
|
||||
@@ -51,8 +51,9 @@
|
||||
</view>
|
||||
<text class="free-left">今日免费问答剩余 {{ freeRemaining }} 次</text>
|
||||
</view>
|
||||
<view class="recharge-go" @click="goRecharge">
|
||||
<text>充值</text>
|
||||
<view class="balance-actions">
|
||||
<view class="recharge-go" @click="goRecharge"><text>充值</text></view>
|
||||
<view class="recharge-go outline" @click="goLedger"><text>明细</text></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -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); }
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
+125
-1
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user