增加后台支付列表,修改前台有问题也能支付成功,增加支付日志
This commit is contained in:
@@ -11,6 +11,7 @@ import AdminSettings from './pages/AdminSettings';
|
||||
import AdminNotificationManager from './pages/AdminNotificationManager';
|
||||
import AdminCreditRecords from './pages/AdminCreditRecords';
|
||||
import AdminPaymentConfig from './pages/AdminPaymentConfig';
|
||||
import AdminPaymentStats from './pages/AdminPaymentStats';
|
||||
import AdminIndustries from './pages/AdminIndustries';
|
||||
import AdminVideoEngines from './pages/AdminVideoEngines';
|
||||
import AdminImageEngines from './pages/AdminImageEngines';
|
||||
@@ -75,6 +76,7 @@ const App = () => {
|
||||
<Route path="menu-configs" element={<AdminMenuConfig />} />
|
||||
<Route path="recharge-packages" element={<AdminRechargePackages />} />
|
||||
<Route path="payment" element={<AdminPaymentConfig />} />
|
||||
<Route path="payment-stats" element={<AdminPaymentStats />} />
|
||||
<Route path="settings" element={<AdminSettings />} />
|
||||
<Route path="notifications" element={<AdminNotificationManager />} />
|
||||
<Route path="operation-logs" element={<AdminOperationLogs />} />
|
||||
|
||||
@@ -211,6 +211,22 @@ export async function batchUpdatePaymentConfigs(configs: Record<string, string>)
|
||||
await api.put('/admin/payment-configs/batch', configs);
|
||||
}
|
||||
|
||||
export async function getPaymentStats(): Promise<{
|
||||
by_status: Record<string, { count: number; amount: number }>;
|
||||
today: { paid_count: number; paid_amount: number };
|
||||
recent: any[];
|
||||
}> {
|
||||
return api.get('/admin/payment-stats');
|
||||
}
|
||||
|
||||
export async function getAdminPaymentOrders(params?: { method?: string; status?: string }): Promise<{ items: any[] }> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.method) qs.set('method', params.method);
|
||||
if (params?.status) qs.set('status', params.status);
|
||||
const suffix = qs.toString() ? `?${qs.toString()}` : '';
|
||||
return api.get(`/admin/payment-orders${suffix}`);
|
||||
}
|
||||
|
||||
export async function getAdminNotifications(): Promise<{ total: number; items: any[] }> {
|
||||
return api.get('/admin/notifications');
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const AdminPaymentConfig: React.FC = () => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [wechatEnabled, setWechatEnabled] = useState(false);
|
||||
const [alipayEnabled, setAlipayEnabled] = useState(false);
|
||||
const [mockMode, setMockMode] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
@@ -31,6 +32,7 @@ const AdminPaymentConfig: React.FC = () => {
|
||||
});
|
||||
setWechatEnabled(map['payment_wechat_enabled'] === 'true');
|
||||
setAlipayEnabled(map['payment_alipay_enabled'] === 'true');
|
||||
setMockMode(map['payment_mock'] === 'true');
|
||||
} catch {
|
||||
message.error('加载支付配置失败');
|
||||
}
|
||||
@@ -43,6 +45,7 @@ const AdminPaymentConfig: React.FC = () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
await batchUpdatePaymentConfigs({
|
||||
payment_mock: String(mockMode),
|
||||
payment_wechat_enabled: String(wechatEnabled),
|
||||
payment_wechat_mch_id: values.wechat_mch_id || '',
|
||||
payment_wechat_api_key: values.wechat_api_key || '',
|
||||
@@ -66,6 +69,27 @@ const AdminPaymentConfig: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
{/* Mock Mode Toggle */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16, background: mockMode ? '#fff7e6' : '#fafbff' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: mockMode ? 'rgba(245,158,11,0.15)' : 'rgba(99,102,241,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 22, color: mockMode ? '#f59e0b' : '#6366f1',
|
||||
}}><DollarOutlined /></div>
|
||||
<div>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>模拟支付模式</Typography.Title>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{mockMode ? '⚠️ 开启后所有充值会直接成功(仅用于测试)' : '关闭 - 使用真实支付渠道'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={mockMode} onChange={setMockMode} checkedChildren="已开启" unCheckedChildren="已关闭" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* WeChat Pay */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Card, Col, Row, Space, Table, Tag, Typography, Statistic, message,
|
||||
} from 'antd';
|
||||
import {
|
||||
DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getPaymentStats } from '../api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
const AdminPaymentStats: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getPaymentStats();
|
||||
setStats(data);
|
||||
} catch {
|
||||
message.error('加载支付统计失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const statusConfig: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
|
||||
paid: { color: 'green', label: '已支付', icon: <CheckCircleOutlined /> },
|
||||
pending: { color: 'gold', label: '待支付', icon: <ClockCircleOutlined /> },
|
||||
cancelled: { color: 'default', label: '已取消', icon: <CloseCircleOutlined /> },
|
||||
};
|
||||
|
||||
const methodConfig: Record<string, { color: string; label: string }> = {
|
||||
alipay: { color: 'blue', label: '支付宝' },
|
||||
wechat: { color: 'green', label: '微信' },
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '订单号', dataIndex: 'order_no', key: 'order_no', width: 200 },
|
||||
{
|
||||
title: '支付方式', dataIndex: 'payment_method', key: 'payment_method', width: 100,
|
||||
render: (m: string) => {
|
||||
const c = methodConfig[m] || { color: 'default', label: m };
|
||||
return <Tag color={c.color}>{c.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '金额', dataIndex: 'amount', key: 'amount', width: 100,
|
||||
render: (a: number) => <Typography.Text strong style={{ color: '#10b981' }}>¥{a.toFixed(2)}</Typography.Text>,
|
||||
},
|
||||
{ title: '积分', dataIndex: 'credits', key: 'credits', width: 80 },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 100,
|
||||
render: (s: string) => {
|
||||
const c = statusConfig[s] || { color: 'default', label: s, icon: null };
|
||||
return <Tag color={c.color} icon={c.icon}>{c.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '支付宝交易号', dataIndex: 'trade_no', key: 'trade_no', width: 200, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 160,
|
||||
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
|
||||
},
|
||||
{
|
||||
title: '支付时间', dataIndex: 'paid_at', key: 'paid_at', width: 160,
|
||||
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
if (!stats) {
|
||||
return <div style={{ padding: 24, color: '#94a3b8' }}>加载中…</div>;
|
||||
}
|
||||
|
||||
const paidInfo = stats.by_status?.paid || { count: 0, amount: 0 };
|
||||
const pendingInfo = stats.by_status?.pending || { count: 0, amount: 0 };
|
||||
const cancelledInfo = stats.by_status?.cancelled || { count: 0, amount: 0 };
|
||||
const totalOrders = paidInfo.count + pendingInfo.count + cancelledInfo.count;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Summary cards */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Statistic
|
||||
title="今日收入"
|
||||
value={stats.today.paid_amount}
|
||||
precision={2}
|
||||
prefix={<DollarOutlined style={{ color: '#10b981' }} />}
|
||||
suffix="元"
|
||||
valueStyle={{ color: '#10b981', fontWeight: 700 }}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{stats.today.paid_count} 笔订单
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Statistic
|
||||
title="累计已支付"
|
||||
value={paidInfo.amount}
|
||||
precision={2}
|
||||
prefix={<DollarOutlined style={{ color: '#6366f1' }} />}
|
||||
suffix="元"
|
||||
valueStyle={{ color: '#6366f1', fontWeight: 700 }}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{paidInfo.count} 笔订单
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} lg={6}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Statistic
|
||||
title="待支付"
|
||||
value={pendingInfo.count}
|
||||
prefix={<ClockCircleOutlined style={{ color: '#f59e0b' }} />}
|
||||
suffix="笔"
|
||||
valueStyle={{ color: '#f59e0b', fontWeight: 700 }}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
¥{pendingInfo.amount.toFixed(2)} 待付
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} lg={6}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Statistic
|
||||
title="已取消"
|
||||
value={cancelledInfo.count}
|
||||
prefix={<CloseCircleOutlined style={{ color: '#94a3b8' }} />}
|
||||
suffix="笔"
|
||||
valueStyle={{ color: '#94a3b8', fontWeight: 700 }}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
¥{cancelledInfo.amount.toFixed(2)} 已取消
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Status breakdown */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
|
||||
title={<Space><DollarOutlined />订单状态分布</Space>}>
|
||||
<Row gutter={16}>
|
||||
{['paid', 'pending', 'cancelled'].map(s => {
|
||||
const info = stats.by_status?.[s] || { count: 0, amount: 0 };
|
||||
const c = statusConfig[s];
|
||||
const pct = totalOrders > 0 ? ((info.count / totalOrders) * 100).toFixed(1) : '0.0';
|
||||
return (
|
||||
<Col span={8} key={s}>
|
||||
<div style={{
|
||||
padding: 16, borderRadius: 10,
|
||||
background: '#fafbff', border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
<Space>
|
||||
<Tag color={c.color} icon={c.icon}>{c.label}</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{pct}%</Typography.Text>
|
||||
</Space>
|
||||
<div style={{ marginTop: 8, fontSize: 20, fontWeight: 700 }}>
|
||||
{info.count} <span style={{ fontSize: 13, color: '#94a3b8', fontWeight: 400 }}>笔</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#64748b', marginTop: 4 }}>
|
||||
¥{info.amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Recent orders table */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}
|
||||
title={<Space><DollarOutlined />最近 50 笔订单</Space>}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={stats.recent}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10, size: 'small' }}
|
||||
scroll={{ x: 1000 }}
|
||||
size="middle"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPaymentStats;
|
||||
@@ -117,6 +117,26 @@ export interface AdminStats {
|
||||
creditsConsumedToday: number;
|
||||
}
|
||||
|
||||
export interface PaymentStats {
|
||||
by_status: Record<string, { count: number; amount: number }>;
|
||||
today: { paid_count: number; paid_amount: number };
|
||||
recent: PaymentOrder[];
|
||||
}
|
||||
|
||||
export interface PaymentOrder {
|
||||
id: string;
|
||||
order_no: string;
|
||||
user_id: string;
|
||||
user?: { username: string };
|
||||
amount: number;
|
||||
credits: number;
|
||||
payment_method: string;
|
||||
status: string;
|
||||
trade_no?: string;
|
||||
paid_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ModelConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add_refund_fields_to_payment_orders
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: ed59aefc83da
|
||||
Create Date: 2026-06-10 12:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a1b2c3d4e5f6'
|
||||
down_revision: Union[str, None] = 'ed59aefc83da'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('payment_orders', sa.Column('refund_trade_no', sa.String(length=128), nullable=True))
|
||||
op.add_column('payment_orders', sa.Column('refunded_at', sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column('payment_orders', sa.Column('refund_amount', sa.Float(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('payment_orders', 'refund_amount')
|
||||
op.drop_column('payment_orders', 'refunded_at')
|
||||
op.drop_column('payment_orders', 'refund_trade_no')
|
||||
@@ -440,6 +440,123 @@ async def batch_update_payment_configs(
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/payment-stats")
|
||||
async def get_payment_stats(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return payment statistics for admin dashboard."""
|
||||
from sqlalchemy import func
|
||||
|
||||
# Status breakdown
|
||||
status_result = await db.execute(
|
||||
select(
|
||||
PaymentOrder.status,
|
||||
func.count().label("count"),
|
||||
func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"),
|
||||
).group_by(PaymentOrder.status)
|
||||
)
|
||||
by_status = {}
|
||||
for row in status_result.all():
|
||||
by_status[row.status] = {"count": row.count, "amount": float(row.amount)}
|
||||
|
||||
# Today's stats
|
||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_result = await db.execute(
|
||||
select(
|
||||
func.count().label("paid_count"),
|
||||
func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"),
|
||||
).where(
|
||||
PaymentOrder.status == "paid",
|
||||
PaymentOrder.paid_at >= today_start,
|
||||
)
|
||||
)
|
||||
today_row = today_result.one()
|
||||
|
||||
# Recent 50 orders
|
||||
recent_result = await db.execute(
|
||||
select(PaymentOrder)
|
||||
.order_by(PaymentOrder.created_at.desc())
|
||||
.limit(50)
|
||||
)
|
||||
recent = recent_result.scalars().all()
|
||||
|
||||
return {
|
||||
"by_status": by_status,
|
||||
"today": {
|
||||
"paid_count": today_row.paid_count,
|
||||
"paid_amount": float(today_row.paid_amount),
|
||||
},
|
||||
"recent": [
|
||||
{
|
||||
"id": o.id,
|
||||
"order_no": o.order_no,
|
||||
"user_id": o.user_id,
|
||||
"amount": o.amount,
|
||||
"credits": o.credits,
|
||||
"payment_method": o.payment_method,
|
||||
"status": o.status,
|
||||
"trade_no": o.trade_no,
|
||||
"paid_at": o.paid_at.isoformat() if o.paid_at else None,
|
||||
"created_at": o.created_at.isoformat() if o.created_at else None,
|
||||
}
|
||||
for o in recent
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/payment-orders")
|
||||
async def get_admin_payment_orders(
|
||||
method: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return paginated payment orders for admin."""
|
||||
query = select(PaymentOrder)
|
||||
if method:
|
||||
query = query.where(PaymentOrder.payment_method == method)
|
||||
if status:
|
||||
query = query.where(PaymentOrder.status == status)
|
||||
|
||||
# Count total
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(query.subquery())
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# Paginated results
|
||||
result = await db.execute(
|
||||
query.order_by(PaymentOrder.created_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
orders = result.scalars().all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"items": [
|
||||
{
|
||||
"id": o.id,
|
||||
"order_no": o.order_no,
|
||||
"user_id": o.user_id,
|
||||
"amount": o.amount,
|
||||
"credits": o.credits,
|
||||
"payment_method": o.payment_method,
|
||||
"status": o.status,
|
||||
"trade_no": o.trade_no,
|
||||
"paid_at": o.paid_at.isoformat() if o.paid_at else None,
|
||||
"created_at": o.created_at.isoformat() if o.created_at else None,
|
||||
}
|
||||
for o in orders
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.put("/payment-configs/{config_id}")
|
||||
async def update_payment_config(
|
||||
config_id: str,
|
||||
@@ -1250,3 +1367,76 @@ async def admin_generate_video(
|
||||
await db.flush()
|
||||
|
||||
return {"message": "ok", "record_id": record_id}
|
||||
|
||||
|
||||
# ── Payment Stats ────────────────────────────────────────
|
||||
|
||||
@router.get("/payment-stats")
|
||||
async def get_payment_stats(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Payment statistics for admin dashboard."""
|
||||
from app.models.payment_order import PaymentOrder
|
||||
from datetime import datetime
|
||||
|
||||
# Count and revenue by status
|
||||
rows = (await db.execute(
|
||||
select(
|
||||
PaymentOrder.status,
|
||||
PaymentOrder.payment_method,
|
||||
func.count(PaymentOrder.id).label("count"),
|
||||
func.coalesce(func.sum(PaymentOrder.amount), 0).label("total_amount"),
|
||||
).group_by(PaymentOrder.status, PaymentOrder.payment_method)
|
||||
)).all()
|
||||
|
||||
by_status: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
s = r.status
|
||||
if s not in by_status:
|
||||
by_status[s] = {"count": 0, "amount": 0.0}
|
||||
by_status[s]["count"] += r.count
|
||||
by_status[s]["amount"] += float(r.total_amount)
|
||||
|
||||
# Recent orders (last 50)
|
||||
recent = (await db.execute(
|
||||
select(PaymentOrder)
|
||||
.order_by(PaymentOrder.created_at.desc())
|
||||
.limit(50)
|
||||
)).scalars().all()
|
||||
|
||||
# Today stats
|
||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_paid = (await db.execute(
|
||||
select(
|
||||
func.count(PaymentOrder.id),
|
||||
func.coalesce(func.sum(PaymentOrder.amount), 0),
|
||||
).where(
|
||||
PaymentOrder.status == "paid",
|
||||
PaymentOrder.paid_at >= today_start,
|
||||
)
|
||||
)).first()
|
||||
today_count, today_amount = (today_paid or (0, 0))
|
||||
|
||||
return {
|
||||
"by_status": by_status,
|
||||
"today": {
|
||||
"paid_count": int(today_count or 0),
|
||||
"paid_amount": float(today_amount or 0),
|
||||
},
|
||||
"recent": [
|
||||
{
|
||||
"id": o.id,
|
||||
"order_no": o.order_no,
|
||||
"user_id": o.user_id,
|
||||
"amount": o.amount,
|
||||
"credits": o.credits,
|
||||
"payment_method": o.payment_method,
|
||||
"status": o.status,
|
||||
"trade_no": o.trade_no,
|
||||
"created_at": _iso(o.created_at),
|
||||
"paid_at": _iso(o.paid_at),
|
||||
}
|
||||
for o in recent
|
||||
],
|
||||
}
|
||||
|
||||
@@ -89,7 +89,10 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
form_data = await request.form()
|
||||
data = dict(form_data)
|
||||
logger.info(f"Alipay callback received: {list(data.keys())}")
|
||||
logger.info(
|
||||
f"ALIPAY_CALLBACK order_no={data.get('out_trade_no')} "
|
||||
f"trade_no={data.get('trade_no', '')} status={data.get('trade_status', '')}"
|
||||
)
|
||||
|
||||
# Verify signature first
|
||||
if not await verify_alipay_callback(data, db):
|
||||
@@ -114,9 +117,40 @@ async def list_orders(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Auto-expire stale pending orders before returning
|
||||
from app.services.payment import _check_and_expire_order
|
||||
result = await db.execute(
|
||||
select(PaymentOrder)
|
||||
.where(PaymentOrder.user_id == current_user.id)
|
||||
.order_by(PaymentOrder.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
orders = result.scalars().all()
|
||||
for o in orders:
|
||||
await _check_and_expire_order(db, o)
|
||||
return orders
|
||||
|
||||
|
||||
@router.post("/orders/{order_no}/cancel")
|
||||
async def cancel_order(
|
||||
order_no: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Cancel a pending order. Only the order owner can cancel, only if still pending."""
|
||||
result = await db.execute(
|
||||
select(PaymentOrder).where(
|
||||
PaymentOrder.order_no == order_no,
|
||||
PaymentOrder.user_id == current_user.id,
|
||||
).limit(1)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="订单不存在")
|
||||
if order.status != "pending":
|
||||
raise HTTPException(status_code=400, detail=f"订单状态为{order.status},无法取消")
|
||||
order.status = "cancelled"
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"ORDER_CANCELLED order_no={order_no} user={current_user.id} amount={order.amount}"
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -57,7 +57,7 @@ class Settings(BaseSettings):
|
||||
ALIPAY_PRIVATE_KEY: str = ""
|
||||
ALIPAY_PUBLIC_KEY: str = ""
|
||||
ALIPAY_NOTIFY_URL: str = ""
|
||||
PAYMENT_MOCK: bool = True
|
||||
PAYMENT_MOCK: bool = False # Default off; use admin panel to enable for testing
|
||||
|
||||
STORAGE_TYPE: str = "local"
|
||||
STORAGE_LOCAL_PATH: str = "./storage/generate/videos"
|
||||
|
||||
@@ -21,6 +21,48 @@ from app.services.log_config import decrypt_data
|
||||
logging.basicConfig(level=logging.INFO if settings.DEBUG else logging.WARNING)
|
||||
|
||||
|
||||
def _setup_payment_logger():
|
||||
"""Configure a dedicated file logger for payment events.
|
||||
|
||||
Logs are written to logs/payment_YYYY-MM-DD.log, rotated daily.
|
||||
30 days of history are retained.
|
||||
"""
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
log_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_file = os.path.join(log_dir, "payment.log")
|
||||
|
||||
payment_logger = logging.getLogger("payment")
|
||||
payment_logger.setLevel(logging.INFO)
|
||||
payment_logger.propagate = False # don't double-log to root
|
||||
|
||||
# Avoid adding duplicate handlers on reload
|
||||
if any(getattr(h, "_payment_file", False) for h in payment_logger.handlers):
|
||||
return
|
||||
|
||||
handler = TimedRotatingFileHandler(
|
||||
log_file,
|
||||
when="midnight",
|
||||
interval=1,
|
||||
backupCount=30,
|
||||
encoding="utf-8",
|
||||
utc=False, # use local time
|
||||
)
|
||||
handler.suffix = "%Y-%m-%d" # files named like payment.log.2026-06-10
|
||||
handler._payment_file = True # type: ignore[attr-defined]
|
||||
handler.setFormatter(logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
))
|
||||
payment_logger.addHandler(handler)
|
||||
# Mirror to console in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
payment_logger.addHandler(logging.StreamHandler())
|
||||
|
||||
|
||||
_setup_payment_logger()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
from app.models import async_session
|
||||
@@ -35,13 +77,31 @@ async def lifespan(app: FastAPI):
|
||||
from app.services.video_queue import task_queue
|
||||
await task_queue.recover()
|
||||
queue_task = asyncio.create_task(task_queue.run())
|
||||
|
||||
|
||||
# Background task: auto-expire pending payment orders
|
||||
async def _order_expiry_loop():
|
||||
from app.services.payment import expire_all_pending_orders
|
||||
from logging import getLogger
|
||||
bg_logger = getLogger("payment")
|
||||
while True:
|
||||
try:
|
||||
async with async_session() as db:
|
||||
n = await expire_all_pending_orders(db)
|
||||
if n > 0:
|
||||
bg_logger.info(f"Auto-expired {n} pending payment order(s)")
|
||||
except Exception as e:
|
||||
bg_logger.error(f"Order expiry loop error: {e}")
|
||||
await asyncio.sleep(60) # check every minute
|
||||
|
||||
expiry_task = asyncio.create_task(_order_expiry_loop())
|
||||
|
||||
app.state.db_session_factory = async_session
|
||||
|
||||
yield
|
||||
|
||||
task_queue.stop()
|
||||
await queue_task
|
||||
expiry_task.cancel()
|
||||
await close_database()
|
||||
await close_redis()
|
||||
|
||||
|
||||
@@ -22,3 +22,9 @@ class PaymentOrder(Base, TimestampMixin):
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# Refund fields
|
||||
refund_trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
refunded_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
refund_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -10,7 +12,32 @@ from app.models.system_config import SystemConfig
|
||||
from app.services.credits import add_credits
|
||||
from app.utils.id_gen import generate_id, generate_order_no
|
||||
|
||||
logger = logging.getLogger("videogen")
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payment logger → logs/payment/YYYY-MM-DD.log (one file per day, keep 30 days)
|
||||
# ---------------------------------------------------------------------------
|
||||
logger = logging.getLogger("payment")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
_log_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs", "payment")
|
||||
os.makedirs(_log_dir, exist_ok=True)
|
||||
|
||||
_file_handler = TimedRotatingFileHandler(
|
||||
os.path.join(_log_dir, "payment.log"),
|
||||
when="midnight",
|
||||
interval=1,
|
||||
backupCount=0,
|
||||
encoding="utf-8",
|
||||
utc=False,
|
||||
)
|
||||
_file_handler.suffix = "%Y-%m-%d"
|
||||
_file_handler.setFormatter(logging.Formatter(
|
||||
"[%(asctime)s] %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
|
||||
))
|
||||
if not logger.handlers:
|
||||
logger.addHandler(_file_handler)
|
||||
|
||||
# Orders pending payment for longer than this are auto-cancelled
|
||||
ORDER_EXPIRE_MINUTES = 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -26,6 +53,46 @@ async def _get_payment_configs(db: AsyncSession) -> dict[str, str]:
|
||||
return {c.key: c.value for c in result.scalars().all()}
|
||||
|
||||
|
||||
async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool:
|
||||
"""If a pending order has passed its expiry, mark it cancelled.
|
||||
Returns True if the order was expired.
|
||||
"""
|
||||
if order.status != "pending":
|
||||
return False
|
||||
expiry = order.created_at + timedelta(minutes=ORDER_EXPIRE_MINUTES)
|
||||
if datetime.now(order.created_at.tzinfo) >= expiry:
|
||||
order.status = "cancelled"
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} "
|
||||
f"amount={order.amount} created_at={order.created_at.isoformat()}"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def expire_all_pending_orders(db: AsyncSession) -> int:
|
||||
"""Background task: mark all expired pending orders as cancelled.
|
||||
Returns the number of orders expired.
|
||||
"""
|
||||
threshold = datetime.now() - timedelta(minutes=ORDER_EXPIRE_MINUTES)
|
||||
result = await db.execute(
|
||||
select(PaymentOrder).where(
|
||||
PaymentOrder.status == "pending",
|
||||
PaymentOrder.created_at <= threshold,
|
||||
)
|
||||
)
|
||||
orders = result.scalars().all()
|
||||
for o in orders:
|
||||
o.status = "cancelled"
|
||||
logger.info(
|
||||
f"ORDER_EXPIRED order_no={o.order_no} user={o.user_id} amount={o.amount}"
|
||||
)
|
||||
if orders:
|
||||
await db.flush()
|
||||
return len(orders)
|
||||
|
||||
|
||||
def _is_mock_mode(db_configs: dict[str, str]) -> bool:
|
||||
"""Check if payment mock mode is enabled (from DB or env)."""
|
||||
db_val = db_configs.get("payment_mock", "")
|
||||
@@ -125,6 +192,10 @@ async def create_recharge_order(
|
||||
)
|
||||
db.add(order)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"ORDER_CREATED order_no={order.order_no} user={user_id} "
|
||||
f"amount={price} credits={total_credits} method={method} mock={mock_mode}"
|
||||
)
|
||||
|
||||
if mock_mode:
|
||||
# Mock: immediately complete payment
|
||||
@@ -371,4 +442,7 @@ async def process_payment_success_by_order_no(db: AsyncSession, order_no: str, t
|
||||
related_id=order.id,
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"Payment success processed: order_no={order_no}, trade_no={trade_no}")
|
||||
logger.info(
|
||||
f"PAYMENT_SUCCESS order_no={order_no} user={order.user_id} "
|
||||
f"amount={order.amount} credits={order.credits} trade_no={trade_no}"
|
||||
)
|
||||
|
||||
@@ -331,6 +331,11 @@ export async function getPaymentOrders(): Promise<any[]> {
|
||||
return api.get('/payments/orders');
|
||||
}
|
||||
|
||||
export async function cancelPaymentOrder(orderNo: string): Promise<void> {
|
||||
return api.post(`/payments/orders/${orderNo}/cancel`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function getCreditRatios(): Promise<any[]> {
|
||||
return api.get('/credits/ratios');
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../store/useAuthStore';
|
||||
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrders, getNotifications, markNotificationRead, getSiteInfo } from '../../api';
|
||||
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrders, cancelPaymentOrder, getNotifications, markNotificationRead, getSiteInfo } from '../../api';
|
||||
import NotificationPopup from '../NotificationPopup';
|
||||
|
||||
interface MenuConfig {
|
||||
@@ -93,6 +93,7 @@ const AppLayout: React.FC = () => {
|
||||
const [paymentMethod, setPaymentMethod] = useState<string>('alipay');
|
||||
const [paying, setPaying] = useState(false);
|
||||
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const currentOrderNoRef = useRef<string | null>(null);
|
||||
const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false });
|
||||
|
||||
// 监听预览弹窗状态,关闭浮动按钮
|
||||
@@ -208,11 +209,16 @@ const AppLayout: React.FC = () => {
|
||||
if (order && order.status === 'paid') {
|
||||
clearInterval(timer);
|
||||
pollingTimerRef.current = null;
|
||||
currentOrderNoRef.current = null;
|
||||
message.success('支付成功!积分已到账');
|
||||
useAuthStore.getState().refreshUser();
|
||||
setQrCodeModalOpen(false);
|
||||
setCurrentPaymentInfo(null);
|
||||
setSelectedPlan(null);
|
||||
} else if (order && order.status === 'cancelled') {
|
||||
clearInterval(timer);
|
||||
pollingTimerRef.current = null;
|
||||
currentOrderNoRef.current = null;
|
||||
}
|
||||
} catch {
|
||||
// ignore polling errors
|
||||
@@ -621,6 +627,7 @@ const AppLayout: React.FC = () => {
|
||||
});
|
||||
setRechargeModalOpen(false);
|
||||
setQrCodeModalOpen(true);
|
||||
currentOrderNoRef.current = order.order_no;
|
||||
// Start polling for payment status
|
||||
startPolling(order.order_no);
|
||||
} else {
|
||||
@@ -650,7 +657,16 @@ const AppLayout: React.FC = () => {
|
||||
{/* QR Code Payment Modal */}
|
||||
<Modal
|
||||
open={qrCodeModalOpen}
|
||||
onCancel={() => { stopPolling(); setQrCodeModalOpen(false); setCurrentPaymentInfo(null); }}
|
||||
onCancel={async () => {
|
||||
stopPolling();
|
||||
// Mark order as cancelled if it's still pending
|
||||
if (currentOrderNoRef.current) {
|
||||
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch {}
|
||||
currentOrderNoRef.current = null;
|
||||
}
|
||||
setQrCodeModalOpen(false);
|
||||
setCurrentPaymentInfo(null);
|
||||
}}
|
||||
footer={null}
|
||||
width={400}
|
||||
closable={false}
|
||||
@@ -750,7 +766,16 @@ const AppLayout: React.FC = () => {
|
||||
<Button
|
||||
size="large"
|
||||
block
|
||||
onClick={() => { stopPolling(); setQrCodeModalOpen(false); setCurrentPaymentInfo(null); setSelectedPlan(null); }}
|
||||
onClick={async () => {
|
||||
stopPolling();
|
||||
if (currentOrderNoRef.current) {
|
||||
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch {}
|
||||
currentOrderNoRef.current = null;
|
||||
}
|
||||
setQrCodeModalOpen(false);
|
||||
setCurrentPaymentInfo(null);
|
||||
setSelectedPlan(null);
|
||||
}}
|
||||
style={{ borderRadius: 10 }}
|
||||
>
|
||||
取消支付
|
||||
|
||||
Reference in New Issue
Block a user