新增支付邮件通知+玩家订单查询功能

1. 支付成功后自动发送付款凭据邮件(仅绑定邮箱的玩家)
   - handleNotify回调 + queryOrder补单两条路径均触发
   - 邮件包含订单号、商品、金额、到账债券、支付时间
   - 异步静默发送,不影响主流程

2. 玩家端新增「我的订单」页面
   - 后端新增my_orders action查询pay_orders表
   - 前端展示订单列表(状态标签、金额、债券、时间)
   - 支持刷新按钮
This commit is contained in:
ypsdf1
2026-07-16 23:31:34 +08:00
parent b90eeb2bcd
commit ace705682c
2 changed files with 241 additions and 0 deletions
+152
View File
@@ -466,6 +466,14 @@ function handleNotify() {
$txIns->execute();
debugLog('[pay notify] callback同步成功', ['out_trade_no' => $outTradeNo, 'player' => $platformPlayer]);
// 发送付款凭据邮件(异步静默)
try {
sendPaymentReceiptEmail($platformPlayer, $outTradeNo, $platformResult['money'] ?? '0.01', $bonds, '在线充值', $now);
} catch (\Throwable $e) {
debugLog('[pay notify] callback同步邮件发送失败', ['player' => $platformPlayer, 'error' => $e->getMessage()]);
}
echo 'success';
exit;
}
@@ -508,6 +516,14 @@ function handleNotify() {
$stmt->execute();
debugLog('[pay notify] 充值交易已写入', ['player' => $row['player_name'], 'bonds' => $row['bond_amount'], 'out_trade_no' => $outTradeNo]);
// 7) 发送付款凭据邮件(异步静默,不影响主流程)
try {
sendPaymentReceiptEmail($row['player_name'], $outTradeNo, $row['money'], (int)$row['bond_amount'], $row['name'] ?? '在线充值', time());
} catch (\Throwable $e) {
debugLog('[pay notify] 邮件发送失败(不影响主流程)', ['player' => $row['player_name'], 'error' => $e->getMessage()]);
}
echo 'success';
exit;
}
@@ -663,6 +679,14 @@ function queryOrder($token) {
}
debugLog('[queryOrder] 平台同步成功', ['out_trade_no' => $outTradeNo, 'player' => $platformPlayer]);
// 发送付款凭据邮件(异步静默)
try {
sendPaymentReceiptEmail($platformPlayer, $outTradeNo, $money, $bonds, '在线充值', $now);
} catch (\Throwable $e) {
debugLog('[queryOrder] 平台同步邮件发送失败', ['player' => $platformPlayer, 'error' => $e->getMessage()]);
}
success([
'out_trade_no' => $outTradeNo,
'status' => 'paid',
@@ -720,6 +744,13 @@ function queryOrder($token) {
$status = 'paid';
debugLog('[queryOrder] 直查平台补单成功', ['out_trade_no' => $outTradeNo, 'player' => $info['player']]);
// 发送付款凭据邮件(异步静默)
try {
sendPaymentReceiptEmail($info['player'], $outTradeNo, $row['money'] ?? '0.01', (int)$row['bond_amount'], $row['name'] ?? '在线充值', $now);
} catch (\Throwable $e) {
debugLog('[queryOrder] 邮件发送失败(不影响主流程)', ['player' => $info['player'], 'error' => $e->getMessage()]);
}
} else {
// 检查是否已过期(5分钟)
$nowSec = time();
@@ -946,6 +977,88 @@ function parseExpireDate($expireStr) {
return ['valid' => false, 'timestamp' => 0, 'display' => '日期格式错误: ' . $expireStr];
}
// ====================================================================
// 邮件:支付成功后发送付款凭据邮件
// ====================================================================
/**
* 发送付款凭据邮件给玩家(仅在玩家绑定了邮箱时发送)
* @param string $playerName 玩家名
* @param string $outTradeNo 订单号
* @param string $money 付款金额
* @param int $bondAmount 到账债券
* @param string $productName 商品名称
* @param int $paidAt 支付时间(Unix时间戳)
*/
function sendPaymentReceiptEmail($playerName, $outTradeNo, $money, $bondAmount, $productName, $paidAt) {
// 1) 查询玩家邮箱
$db = getDB();
try {
$stmt = $db->prepare("SELECT email FROM users WHERE player_name = :player");
$stmt->bindValue(':player', $playerName, SQLITE3_TEXT);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
} catch (\Throwable $e) {
debugLog('[sendPaymentReceiptEmail] 查询邮箱失败', ['player' => $playerName, 'error' => $e->getMessage()]);
return;
}
if (!$row || empty($row['email'])) {
debugLog('[sendPaymentReceiptEmail] 玩家未绑定邮箱,跳过', ['player' => $playerName]);
return;
}
$email = trim($row['email']);
// 简单邮箱格式校验
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
debugLog('[sendPaymentReceiptEmail] 邮箱格式无效', ['player' => $playerName, 'email' => $email]);
return;
}
// 2) 构建邮件内容
$paidTimeStr = date('Y-m-d H:i:s', $paidAt);
$baseUrl = getBaseUrl();
$orderLink = $baseUrl . '/player.php?page=orders';
$subject = '[Sdf1] 付款凭据 - 订单 ' . $outTradeNo;
$html = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">'
. '<meta name="viewport" content="width=device-width, initial-scale=1.0">'
. '<title>付款凭据</title></head><body style="margin:0;padding:0;background:#f4f6f9;font-family:Segoe UI,Tahoma,sans-serif;">'
. '<div style="max-width:560px;margin:0 auto;padding:24px;">'
. '<div style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 6px 24px rgba(0,0,0,0.08);">'
// 头部
. '<div style="background:linear-gradient(135deg,#1a7f37,#156d2e);padding:28px 32px;color:#fff;">'
. '<h1 style="margin:0;font-size:22px;">✅ 付款成功</h1>'
. '<p style="margin:8px 0 0;opacity:.9;font-size:14px;">Sdf1 Minecraft 充值中心</p></div>'
// 正文
. '<div style="padding:32px;">'
. '<p style="margin:0 0 16px;font-size:15px;color:#333;">亲爱的 <b>' . htmlspecialchars($playerName) . '</b>:</p>'
. '<p style="margin:0 0 20px;font-size:15px;color:#555;line-height:1.7;">您的充值订单已支付成功,以下是您的付款凭据:</p>'
// 订单信息卡片
. '<div style="background:#f6f8fa;border:1px solid #d0d7de;border-radius:8px;padding:20px;margin:0 0 24px;">'
. '<table style="width:100%;border-collapse:collapse;font-size:14px;">'
. '<tr><td style="padding:8px 0;color:#656d76;width:100px;">订单号</td><td style="padding:8px 0;color:#1f2328;font-weight:600;">' . htmlspecialchars($outTradeNo) . '</td></tr>'
. '<tr><td style="padding:8px 0;color:#656d76;border-top:1px solid #eee;">商品</td><td style="padding:8px 0;color:#1f2328;border-top:1px solid #eee;">' . htmlspecialchars($productName) . '</td></tr>'
. '<tr><td style="padding:8px 0;color:#656d76;border-top:1px solid #eee;">付款金额</td><td style="padding:8px 0;color:#cf222e;font-weight:600;border-top:1px solid #eee;">¥' . htmlspecialchars($money) . '</td></tr>'
. '<tr><td style="padding:8px 0;color:#656d76;border-top:1px solid #eee;">到账债券</td><td style="padding:8px 0;color:#1a7f37;font-weight:600;border-top:1px solid #eee;">' . intval($bondAmount) . ' 债券</td></tr>'
. '<tr><td style="padding:8px 0;color:#656d76;border-top:1px solid #eee;">支付时间</td><td style="padding:8px 0;color:#1f2328;border-top:1px solid #eee;">' . $paidTimeStr . '</td></tr>'
. '</table></div>'
// 提示
. '<p style="margin:0 0 12px;font-size:14px;color:#555;line-height:1.7;">债券将在游戏内自动发放,如未到账请稍候或联系管理员。</p>'
. '<div style="text-align:center;margin:0 0 8px;"><a href="' . htmlspecialchars($orderLink) . '" style="display:inline-block;background:#1a7f37;color:#fff;text-decoration:none;padding:12px 28px;border-radius:8px;font-size:14px;font-weight:600;">查看我的订单</a></div>'
. '<p style="margin:24px 0 0;font-size:12px;color:#999;border-top:1px solid #eee;padding-top:16px;">本邮件由系统自动发送,请勿直接回复。如有疑问请联系管理员。</p>'
. '</div></div></div></body></html>';
// 3) 发送邮件
$headers = "From: " . SMTP_SENDER_NAME . " <" . SMTP_USER . ">\r\n"
. "Reply-To: " . SMTP_USER . "\r\n"
. "MIME-Version: 1.0\r\n"
. "Content-Type: text/html; charset=UTF-8\r\n";
smtpSendEmail(SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, $email, $subject, $html, $headers, SMTP_USE_SSL);
debugLog('[sendPaymentReceiptEmail] 邮件发送成功', ['player' => $playerName, 'email' => $email, 'out_trade_no' => $outTradeNo]);
}
function getShopProducts() {
$db = getOrdersDB();
@@ -1019,6 +1132,42 @@ function getShopProducts() {
success(['products' => $products]);
}
// ====================================================================
// 玩家订单查询
// ====================================================================
function getPlayerOrders($token) {
$info = validateTokenSilent($token);
if (!$info || empty($info['player'])) {
error('登录状态无效', 401);
}
$player = $info['player'];
$db = getOrdersDB();
ensurePayOrdersTable($db);
// 查询该玩家的所有订单(最近50条)
$stmt = $db->prepare("SELECT out_trade_no, trade_no, player_name, money, bond_amount, status, name, created_at, paid_at FROM pay_orders WHERE player_name = :p ORDER BY created_at DESC LIMIT 50");
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
$result = $stmt->execute();
$orders = [];
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$orders[] = [
'out_trade_no' => $row['out_trade_no'],
'trade_no' => $row['trade_no'] ?? '',
'money' => $row['money'],
'bond_amount' => (int)$row['bond_amount'],
'status' => $row['status'],
'name' => $row['name'] ?? '在线充值',
'created_at' => (int)$row['created_at'],
'paid_at' => $row['paid_at'] ? (int)$row['paid_at'] : null,
];
}
success(['orders' => $orders, 'player' => $player]);
}
// ====================================================================
// 入口分发
// ====================================================================
@@ -1046,6 +1195,9 @@ try {
case 'get_shop_products':
getShopProducts();
break;
case 'my_orders':
getPlayerOrders(getParam('token'));
break;
default:
error('未知操作: ' . $action);
}
+89
View File
@@ -277,6 +277,7 @@ if ($currentVersion !== $BUILD_VERSION) {
<div class="sidebar-item" data-page="cdk" onclick="switchPage('cdk')">🎁 CDK兑换</div>
<div class="sidebar-item" data-page="balance" onclick="switchPage('balance')">💰 余额查询</div>
<div class="sidebar-item" data-page="recharge" onclick="switchPage('recharge')">💳 在线充值</div>
<div class="sidebar-item" data-page="orders" onclick="switchPage('orders')">📋 我的订单</div>
<div class="sidebar-item" data-page="groups" onclick="switchPage('groups')">👥 用户组</div>
<div class="sidebar-item" data-page="ticket" onclick="switchPage('ticket')">📋 工单系统</div>
</div>
@@ -1052,6 +1053,7 @@ if ($currentVersion !== $BUILD_VERSION) {
else if (page === 'account') renderAccount(c);
else if (page === 'ticket') renderTicket(c);
else if (page === 'recharge') renderRecharge(c);
else if (page === 'orders') renderMyOrders(c);
}
// ==================== 债券在线充值(动态加载商品) ====================
@@ -1472,6 +1474,93 @@ if ($currentVersion !== $BUILD_VERSION) {
}
}
// ==================== 我的订单 ====================
async function renderMyOrders(el) {
el.innerHTML = '<div class="card" style="max-width:680px;margin:0 auto"><div style="text-align:center;padding:20px;color:var(--dim)">加载订单中...</div></div>';
try {
const res = await api('pay.php', {action: 'my_orders'});
if (!res.success || !res.data || !res.data.orders) {
el.innerHTML = '<div class="card" style="max-width:680px;margin:0 auto"><div style="text-align:center;padding:20px;color:var(--red)">加载失败: ' + (res.message || '未知错误') + '</div></div>';
return;
}
const orders = res.data.orders;
if (orders.length === 0) {
el.innerHTML = `
<div class="card" style="max-width:680px;margin:0 auto">
<h2 style="margin:0 0 6px">📋 我的订单</h2>
<p style="color:var(--dim);font-size:13px;margin:0 0 18px">查看您的在线充值订单记录。</p>
<div style="text-align:center;padding:30px;color:var(--dim)">
<div style="font-size:40px;margin-bottom:12px">📭</div>
<div style="font-size:14px">暂无订单记录</div>
<div style="font-size:12px;margin-top:8px;color:var(--dim)">前往「在线充值」发起第一笔订单</div>
</div>
</div>
`;
return;
}
// 状态标签样式
const statusMap = {
'paid': { text: '✅ 已支付', color: '#1a7f37', bg: '#dafbe1' },
'created': { text: '⏳ 待支付', color: '#9a6700', bg: '#fff8c5' },
'expired': { text: '❌ 已过期', color: '#656d76', bg: '#f6f8fa' },
'failed': { text: '⚠️ 失败', color: '#cf222e', bg: '#ffebe9' },
};
let html = `
<div class="card" style="max-width:680px;margin:0 auto">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<div>
<h2 style="margin:0 0 4px">📋 我的订单</h2>
<p style="color:var(--dim);font-size:12px;margin:0">共 ${orders.length} 条记录</p>
</div>
<button class="btn" style="font-size:12px;padding:6px 14px" onclick="renderMyOrders(document.getElementById('content'))">🔄 刷新</button>
</div>
`;
orders.forEach(order => {
const st = statusMap[order.status] || { text: order.status, color: '#656d76', bg: '#f6f8fa' };
const createdTime = order.created_at ? new Date(order.created_at * 1000).toLocaleString('zh-CN', {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}) : '-';
const paidTime = order.paid_at ? new Date(order.paid_at * 1000).toLocaleString('zh-CN', {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}) : '-';
html += `
<div style="border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin-bottom:10px;transition:all 0.2s">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px">
<div>
<div style="font-size:14px;font-weight:600;color:var(--text)">${escHtml(order.name)}</div>
<div style="font-size:11px;color:var(--dim);margin-top:2px;font-family:monospace">${escHtml(order.out_trade_no)}</div>
</div>
<span style="font-size:12px;color:${st.color};background:${st.bg};border-radius:999px;padding:3px 10px;white-space:nowrap">${st.text}</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:baseline">
<div style="font-size:13px;color:var(--dim)">
<span>创建: ${createdTime}</span>
${order.paid_at ? '<span style="margin-left:12px">支付: ' + paidTime + '</span>' : ''}
</div>
<div style="text-align:right">
<div style="font-size:16px;font-weight:700;color:var(--red)">¥${escHtml(order.money)}</div>
<div style="font-size:12px;color:var(--green)">→ ${order.bond_amount} 债券</div>
</div>
</div>
</div>
`;
});
html += '</div>';
el.innerHTML = html;
} catch (e) {
el.innerHTML = '<div class="card" style="max-width:680px;margin:0 auto"><div style="text-align:center;padding:20px;color:var(--red)">加载失败: ' + e.message + '</div></div>';
}
}
function escHtml(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ===== 商城(分类标签页) =====
let shopItems = [];
let shopCategories = [];