init: 微信支付 Native 页面 (Express + JSON 存储)

This commit is contained in:
2026-07-12 14:44:44 +08:00
commit d46977be14
13 changed files with 2392 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
data/orders.json
config.js
+26
View File
@@ -0,0 +1,26 @@
const express = require('express');
const path = require('path');
const config = require('./config');
const app = express();
// Body parsing
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(express.text({ type: ['text/xml', 'application/xml'] }));
// Static files
app.use(express.static(path.join(__dirname, 'public')));
// View engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Routes
app.use('/', require('./routes/index'));
app.use('/pay', require('./routes/pay'));
// Start server
app.listen(config.server.port, config.server.host, () => {
console.log(`Server running at http://localhost:${config.server.port}`);
});
+23
View File
@@ -0,0 +1,23 @@
module.exports = {
// WeChat Pay configuration - replace with your own credentials
wechat: {
appid: 'your_appid',
mchid: 'your_mchid',
key: 'your_api_key',
notifyUrl: 'https://your-domain.com/pay/notify',
apiBase: 'https://api.mch.weixin.qq.com',
},
// Server configuration
server: {
port: 3000,
host: '0.0.0.0',
},
// Product list
products: [
{ id: 1, name: '商品A', price: 1, desc: '测试商品A - 1分钱' },
{ id: 2, name: '商品B', price: 100, desc: '测试商品B - 1元' },
{ id: 3, name: '商品C', price: 1000, desc: '测试商品C - 10元' },
],
};
+1824
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "wechat-pay-native",
"version": "1.0.0",
"description": "WeChat Pay Native payment page with Express",
"main": "app.js",
"scripts": {
"start": "node app.js",
"dev": "nodemon app.js"
},
"dependencies": {
"axios": "^1.7.0",
"ejs": "^3.1.10",
"express": "^4.19.0",
"qrcode": "^1.5.4",
"uuid": "^9.0.1",
"xml2js": "^0.6.2"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}
+151
View File
@@ -0,0 +1,151 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
padding: 40px 20px;
}
.container {
max-width: 500px;
width: 100%;
}
h1 {
text-align: center;
color: #fff;
margin-bottom: 30px;
font-size: 28px;
}
/* Product List */
.product-list { display: flex; flex-direction: column; gap: 16px; }
.product-card {
background: #fff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
.product-card h2 { font-size: 20px; color: #333; margin-bottom: 8px; }
.price { font-size: 28px; color: #e74c3c; font-weight: bold; margin-bottom: 8px; }
.desc { color: #888; font-size: 14px; margin-bottom: 16px; }
.btn {
display: inline-block;
background: #07c160;
color: #fff;
padding: 10px 24px;
border-radius: 6px;
text-decoration: none;
font-size: 16px;
border: none;
cursor: pointer;
text-align: center;
}
.btn:hover { background: #06ad56; }
.btn-back { background: #666; margin-top: 16px; display: block; }
.btn-back:hover { background: #555; }
/* Pay */
.pay-card, .error-card, .success-card {
background: #fff;
border-radius: 12px;
padding: 32px 24px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
text-align: center;
}
.order-info {
margin: 20px 0;
text-align: left;
background: #f9f9f9;
padding: 16px;
border-radius: 8px;
}
.order-info p { margin: 6px 0; color: #555; font-size: 14px; }
.order-info p span { float: right; color: #333; font-weight: 500; }
.qrcode-wrapper { margin: 24px 0; }
.qrcode-wrapper img { width: 260px; height: 260px; }
.hint { color: #888; font-size: 14px; margin-top: 12px; }
.status {
padding: 12px;
border-radius: 6px;
font-size: 16px;
margin: 16px 0;
}
.status.pending { background: #fff3cd; color: #856404; }
.status.success { background: #d4edda; color: #155724; }
.status.failed { background: #f8d7da; color: #721c24; }
/* Error */
.error-card h1 { color: #e74c3c; }
.error-card p { color: #666; margin: 16px 0; }
/* Success */
.success-card h1 { color: #07c160; }
/* Navigation */
.nav-btn { display: block; margin-top: 20px; background: rgba(255,255,255,0.2); text-align: center; }
.nav-btn:hover { background: rgba(255,255,255,0.3); }
/* Orders */
.orders-card {
background: #fff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
overflow-x: auto;
}
.container:has(.orders-card) { max-width: 700px; }
.orders-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.orders-table th {
background: #f5f5f5;
padding: 10px 8px;
text-align: left;
font-weight: 600;
color: #555;
border-bottom: 2px solid #eee;
}
.orders-table td {
padding: 10px 8px;
border-bottom: 1px solid #f0f0f0;
color: #333;
}
.orders-table tbody tr:hover { background: #fafafa; }
.order-id { font-family: monospace; color: #888; font-size: 12px; }
.empty { text-align: center; color: #999; padding: 40px 0; font-size: 16px; }
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
}
.badge-creating { background: #e8e8e8; color: #666; }
.badge-pending { background: #fff3cd; color: #856404; }
.badge-success { background: #d4edda; color: #155724; }
.badge-failed { background: #f8d7da; color: #721c24; }
+9
View File
@@ -0,0 +1,9 @@
const express = require('express');
const router = express.Router();
const config = require('../config');
router.get('/', (req, res) => {
res.render('index', { products: config.products });
});
module.exports = router;
+136
View File
@@ -0,0 +1,136 @@
const express = require('express');
const router = express.Router();
const crypto = require('crypto');
const axios = require('axios');
const xml2js = require('xml2js');
const { v4: uuidv4 } = require('uuid');
const QRCode = require('qrcode');
const config = require('../config');
const storage = require('../utils/storage');
function buildSign(params, key) {
const str = Object.keys(params)
.filter(k => params[k] !== '' && k !== 'sign')
.sort()
.map(k => `${k}=${params[k]}`)
.join('&') + `&key=${key}`;
return crypto.createHash('md5').update(str, 'utf-8').digest('hex').toUpperCase();
}
function buildXml(obj) {
let xml = '<xml>';
for (const [k, v] of Object.entries(obj)) {
xml += `<${k}>${v}</${k}>`;
}
xml += '</xml>';
return xml;
}
// Step 1: Create order page - show QR code
router.get('/create/:productId', async (req, res) => {
const product = config.products.find(p => p.id === parseInt(req.params.productId));
if (!product) return res.status(404).send('商品不存在');
const orderId = uuidv4().replace(/-/g, '').substring(0, 28);
const order = {
orderId,
productId: product.id,
productName: product.name,
totalFee: product.price,
status: 'CREATING',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
storage.saveOrder(order);
try {
const nonceStr = uuidv4().replace(/-/g, '').substring(0, 16);
const unifiedParams = {
appid: config.wechat.appid,
mch_id: config.wechat.mchid,
nonce_str: nonceStr,
body: product.name,
out_trade_no: orderId,
total_fee: product.price,
spbill_create_ip: req.ip || '127.0.0.1',
notify_url: config.wechat.notifyUrl,
trade_type: 'NATIVE',
};
unifiedParams.sign = buildSign(unifiedParams, config.wechat.key);
const xml = buildXml(unifiedParams);
const resp = await axios.post(
`${config.wechat.apiBase}/pay/unifiedorder`,
xml,
{ headers: { 'Content-Type': 'text/xml' } }
);
const parsed = await xml2js.parseStringPromise(resp.data, { explicitArray: false });
const result = parsed.xml;
if (result.return_code !== 'SUCCESS' || result.result_code !== 'SUCCESS') {
order.status = 'FAILED';
storage.saveOrder(order);
return res.render('pay', { error: `下单失败: ${result.err_code_des || result.return_msg}` });
}
order.codeUrl = result.code_url;
order.status = 'PENDING';
storage.saveOrder(order);
const qrDataUrl = await QRCode.toDataURL(result.code_url, { width: 300, margin: 2 });
res.render('pay', { order, qrDataUrl, error: null });
} catch (err) {
order.status = 'FAILED';
storage.saveOrder(order);
res.render('pay', { error: `系统错误: ${err.message}` });
}
});
// Step 2: Poll payment status
router.get('/status/:orderId', (req, res) => {
const order = storage.findOrder(req.params.orderId);
if (!order) return res.json({ status: 'NOT_FOUND' });
res.json({ status: order.status, orderId: order.orderId });
});
// Step 3: WeChat payment notification callback
router.post('/notify', async (req, res) => {
if (!req.body) {
return res.send(buildXml({ return_code: 'FAIL', return_msg: '空请求体' }));
}
try {
const parsed = await xml2js.parseStringPromise(req.body, { explicitArray: false });
const data = parsed.xml;
const expectedSign = buildSign(data, config.wechat.key);
if (data.sign !== expectedSign) {
return res.send(buildXml({ return_code: 'FAIL', return_msg: '签名失败' }));
}
if (data.return_code === 'SUCCESS' && data.result_code === 'SUCCESS') {
storage.updateOrderStatus(data.out_trade_no, 'SUCCESS', data.transaction_id);
return res.send(buildXml({ return_code: 'SUCCESS', return_msg: 'OK' }));
}
res.send(buildXml({ return_code: 'FAIL', return_msg: data.err_code_des || '支付失败' }));
} catch (err) {
res.send(buildXml({ return_code: 'FAIL', return_msg: '解析失败' }));
}
});
// Step 4: Order list
router.get('/orders', (req, res) => {
const orders = storage.readOrders().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
res.render('orders', { orders });
});
// Success page
router.get('/success/:orderId', (req, res) => {
const order = storage.findOrder(req.params.orderId);
if (!order) return res.status(404).send('订单不存在');
res.render('success', { order });
});
module.exports = router;
+47
View File
@@ -0,0 +1,47 @@
const fs = require('fs');
const path = require('path');
const DATA_FILE = path.join(__dirname, '..', 'data', 'orders.json');
function readOrders() {
try {
const raw = fs.readFileSync(DATA_FILE, 'utf-8');
return JSON.parse(raw);
} catch {
return [];
}
}
function writeOrders(orders) {
fs.writeFileSync(DATA_FILE, JSON.stringify(orders, null, 2), 'utf-8');
}
function findOrder(orderId) {
const orders = readOrders();
return orders.find(o => o.orderId === orderId) || null;
}
function saveOrder(order) {
const orders = readOrders();
const idx = orders.findIndex(o => o.orderId === order.orderId);
if (idx !== -1) {
orders[idx] = order;
} else {
orders.push(order);
}
writeOrders(orders);
}
function updateOrderStatus(orderId, status, transactionId) {
const orders = readOrders();
const order = orders.find(o => o.orderId === orderId);
if (order) {
order.status = status;
if (transactionId) order.transactionId = transactionId;
order.updatedAt = new Date().toISOString();
writeOrders(orders);
}
return order;
}
module.exports = { readOrders, findOrder, saveOrder, updateOrderStatus };
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>微信支付 - 商品列表</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="container">
<h1>📦 商品列表</h1>
<div class="product-list">
<% products.forEach(product => { %>
<div class="product-card">
<h2><%= product.name %></h2>
<p class="price">¥ <%= (product.price / 100).toFixed(2) %></p>
<p class="desc"><%= product.desc %></p>
<a href="/pay/create/<%= product.id %>" class="btn">立即购买</a>
</div>
<% }) %>
</div>
<a href="/pay/orders" class="btn nav-btn">📋 订单记录</a>
</div>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>订单记录</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="container">
<h1>📋 订单记录</h1>
<div class="orders-card">
<% if (orders.length === 0) { %>
<p class="empty">暂无订单</p>
<% } else { %>
<table class="orders-table">
<thead>
<tr>
<th>订单号</th>
<th>商品</th>
<th>金额</th>
<th>状态</th>
<th>时间</th>
</tr>
</thead>
<tbody>
<% orders.forEach(order => { %>
<tr>
<td class="order-id" title="<%= order.orderId %>"><%= order.orderId.substring(0, 12) %>...</td>
<td><%= order.productName %></td>
<td>¥ <%= (order.totalFee / 100).toFixed(2) %></td>
<td>
<% const statusMap = { CREATING: '创建中', PENDING: '待支付', SUCCESS: '已支付', FAILED: '失败' }; %>
<span class="badge badge-<%= order.status.toLowerCase() %>"><%= statusMap[order.status] || order.status %></span>
</td>
<td><%= new Date(order.createdAt).toLocaleString() %></td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
</div>
<a href="/" class="btn nav-btn">← 返回首页</a>
</div>
</body>
</html>
+59
View File
@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>微信支付 - 扫码支付</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="container">
<% if (error) { %>
<div class="error-card">
<h1>❌ 下单失败</h1>
<p><%= error %></p>
<a href="/" class="btn">返回重试</a>
</div>
<% } else { %>
<div class="pay-card">
<h1>⚡ 微信扫码支付</h1>
<div class="order-info">
<p>订单号: <span><%= order.orderId %></span></p>
<p>商品: <span><%= order.productName %></span></p>
<p>金额: <span>¥ <%= (order.totalFee / 100).toFixed(2) %></span></p>
</div>
<div class="qrcode-wrapper">
<img src="<%= qrDataUrl %>" alt="支付二维码">
<p class="hint">请使用微信扫描二维码支付</p>
</div>
<div id="status" class="status pending">等待支付...</div>
<a href="/" class="btn btn-back">返回首页</a>
</div>
<% } %>
</div>
<script>
<% if (!error) { %>
const orderId = '<%= order.orderId %>';
const checkStatus = () => {
fetch('/pay/status/' + orderId)
.then(r => r.json())
.then(data => {
const el = document.getElementById('status');
if (data.status === 'SUCCESS') {
el.className = 'status success';
el.textContent = '✅ 支付成功!';
clearInterval(timer);
setTimeout(() => { window.location.href = '/pay/success/' + orderId; }, 1500);
} else if (data.status === 'FAILED') {
el.className = 'status failed';
el.textContent = '❌ 支付失败';
clearInterval(timer);
}
});
};
const timer = setInterval(checkStatus, 2000);
<% } %>
</script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>支付成功</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="container">
<div class="success-card">
<h1>✅ 支付成功</h1>
<div class="order-info">
<p>订单号: <span><%= order.orderId %></span></p>
<p>商品: <span><%= order.productName %></span></p>
<p>金额: <span>¥ <%= (order.totalFee / 100).toFixed(2) %></span></p>
</div>
<a href="/" class="btn">继续购物</a>
</div>
</div>
</body>
</html>