init: 微信支付 Native 页面 (Express + JSON 存储)
This commit is contained in:
@@ -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
@@ -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;
|
||||
Reference in New Issue
Block a user