import crypto from 'node:crypto'; export function loadRechargeConfig() { const tiers = (process.env.H5_RECHARGE_TIERS_CENTS ?? '500,1000,3000,5000,10000,20000') .split(',') .map((value) => Number(value.trim())) .filter((value) => Number.isFinite(value) && value > 0); return { tiersCents: tiers.length ? tiers : [500, 1000, 3000, 5000, 10000, 20000], minRechargeCents: Number(process.env.H5_MIN_RECHARGE_CENTS ?? 500), orderTtlMs: Number(process.env.H5_RECHARGE_ORDER_TTL_MS ?? 15 * 60 * 1000), maxPendingOrders: Number(process.env.H5_RECHARGE_MAX_PENDING ?? 3), dailyLimitCents: Number(process.env.H5_RECHARGE_DAILY_LIMIT_CENTS ?? 200_000), }; } export function isAllowedRechargeAmount(amountCents, config = loadRechargeConfig()) { return config.tiersCents.includes(Number(amountCents)); } export function buildInsufficientBalancePayload(balanceCents, config = loadRechargeConfig()) { return { code: 'INSUFFICIENT_BALANCE', message: '余额不足,请充值后继续使用', balanceCents: Number(balanceCents ?? 0), minRechargeCents: config.minRechargeCents, suggestedTiers: config.tiersCents, }; } function createOutTradeNo() { const stamp = Date.now().toString(36).toUpperCase(); const rand = crypto.randomBytes(4).toString('hex').toUpperCase(); return `TK${stamp}${rand}`.slice(0, 32); } function mapOrderRow(row) { if (!row) return null; return { id: row.id, userId: row.user_id, amountCents: Number(row.amount_cents), channel: row.channel, status: row.status, payMode: row.pay_mode, outTradeNo: row.out_trade_no, providerTxn: row.provider_txn, paidAt: row.paid_at == null ? null : Number(row.paid_at), expireAt: Number(row.expire_at), createdAt: Number(row.created_at), codeUrl: row.code_url ?? null, h5Url: row.h5_url ?? null, }; } export function createRechargeService(pool, { userAuth, wechatPay, config = loadRechargeConfig() } = {}) { const expireStaleOrders = async (userId) => { const now = Date.now(); await pool.query( `UPDATE h5_payment_orders SET status = 'expired', updated_at = ? WHERE user_id = ? AND status = 'pending' AND expire_at <= ?`, [now, userId, now], ); }; const countPendingOrders = async (userId) => { const now = Date.now(); const [rows] = await pool.query( `SELECT COUNT(*) AS total FROM h5_payment_orders WHERE user_id = ? AND status = 'pending' AND expire_at > ?`, [userId, now], ); return Number(rows[0]?.total ?? 0); }; const sumPaidToday = async (userId) => { const start = new Date(); start.setHours(0, 0, 0, 0); const [rows] = await pool.query( `SELECT COALESCE(SUM(amount_cents), 0) AS total FROM h5_payment_orders WHERE user_id = ? AND status = 'paid' AND paid_at >= ?`, [userId, start.getTime()], ); return Number(rows[0]?.total ?? 0); }; const getOrderById = async (orderId) => { const [rows] = await pool.query(`SELECT * FROM h5_payment_orders WHERE id = ? LIMIT 1`, [ orderId, ]); return mapOrderRow(rows[0]); }; const getOrderByOutTradeNo = async (outTradeNo) => { const [rows] = await pool.query( `SELECT * FROM h5_payment_orders WHERE out_trade_no = ? LIMIT 1`, [outTradeNo], ); return mapOrderRow(rows[0]); }; const getBillingConfig = async (userId) => { const user = await userAuth.getUserById(userId); return { wechatEnabled: Boolean(wechatPay?.enabled), tiersCents: config.tiersCents, minRechargeCents: config.minRechargeCents, balanceCents: user ? Number(user.balance_cents ?? 0) : 0, }; }; const createOrder = async ({ userId, amountCents, payScene, clientIp }) => { const amount = Number(amountCents); if (!isAllowedRechargeAmount(amount, config)) { return { ok: false, message: '充值金额不在允许档位内' }; } if (!wechatPay?.enabled) { return { ok: false, message: '微信支付尚未配置,请联系管理员' }; } const user = await userAuth.getUserById(userId); if (!user) return { ok: false, message: '用户不存在' }; if (user.status === 'disabled') return { ok: false, message: '账户已禁用' }; await expireStaleOrders(userId); const pending = await countPendingOrders(userId); if (pending >= config.maxPendingOrders) { return { ok: false, message: '待支付订单过多,请先完成或等待过期后再试' }; } const paidToday = await sumPaidToday(userId); if (paidToday + amount > config.dailyLimitCents) { return { ok: false, message: '已超过今日充值上限,请明日再试' }; } const now = Date.now(); const orderId = crypto.randomUUID(); const outTradeNo = createOutTradeNo(); const expireAt = now + config.orderTtlMs; const description = `TKMind账户充值¥${(amount / 100).toFixed(2)}`; const mode = payScene === 'jsapi' ? 'jsapi' : payScene === 'h5' ? 'h5' : 'native'; let codeUrl = null; let h5Url = null; let jsapiParams = null; try { if (mode === 'jsapi') { const appId = wechatPay.appId; if (!appId) { return { ok: false, message: '微信支付 AppID 未配置' }; } const openid = await userAuth.getWechatOpenidForUser(userId, appId); if (!openid) { return { ok: false, message: '请先用微信登录并绑定账号后再充值', }; } const result = await wechatPay.createJsapiOrder({ outTradeNo, description, amountCents: amount, clientIp, openid, }); jsapiParams = result.jsapiParams; } else if (mode === 'h5') { const result = await wechatPay.createH5Order({ outTradeNo, description, amountCents: amount, clientIp, }); h5Url = result.h5Url; } else { const result = await wechatPay.createNativeOrder({ outTradeNo, description, amountCents: amount, clientIp, }); codeUrl = result.codeUrl; } } catch (err) { return { ok: false, message: err instanceof Error ? err.message : '创建支付订单失败', }; } await pool.query( `INSERT INTO h5_payment_orders (id, user_id, amount_cents, channel, status, pay_mode, out_trade_no, code_url, h5_url, expire_at, client_ip, created_at, updated_at) VALUES (?, ?, ?, 'wechat', 'pending', ?, ?, ?, ?, ?, ?, ?, ?)`, [ orderId, userId, amount, mode, outTradeNo, codeUrl, h5Url, expireAt, clientIp ?? null, now, now, ], ); return { ok: true, order: { id: orderId, amountCents: amount, status: 'pending', payMode: mode, expireAt, codeUrl, h5Url, jsapiParams, }, }; }; const fulfillOrder = async (order, transaction) => { if (!order || order.status === 'paid') { return { ok: true, alreadyPaid: true }; } if (order.status !== 'pending') { return { ok: false, message: '订单状态不可支付' }; } const tradeState = transaction?.trade_state ?? (transaction?.result_code === 'SUCCESS' ? 'SUCCESS' : transaction?.result_code) ?? transaction?.trade_state_desc; if (tradeState && tradeState !== 'SUCCESS') { return { ok: false, message: `支付未成功: ${tradeState}` }; } const paidAmount = Number( transaction?.amount?.total ?? transaction?.amount?.payer_total ?? transaction?.total_fee ?? 0, ); if (paidAmount !== order.amountCents) { return { ok: false, message: '支付金额与订单不一致' }; } const now = Date.now(); const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [rows] = await conn.query( `SELECT * FROM h5_payment_orders WHERE id = ? FOR UPDATE`, [order.id], ); const locked = mapOrderRow(rows[0]); if (!locked) { await conn.rollback(); return { ok: false, message: '订单不存在' }; } if (locked.status === 'paid') { await conn.commit(); return { ok: true, alreadyPaid: true }; } if (locked.status !== 'pending') { await conn.rollback(); return { ok: false, message: '订单状态不可支付' }; } const recharge = await userAuth.recharge(locked.userId, locked.amountCents, null, '用户自助充值', { paymentOrderId: locked.id, conn, }); if (!recharge.ok) { await conn.rollback(); return recharge; } await conn.query( `UPDATE h5_payment_orders SET status = 'paid', provider_txn = ?, paid_at = ?, updated_at = ? WHERE id = ?`, [transaction.transaction_id ?? transaction.out_trade_no ?? null, now, now, locked.id], ); await conn.commit(); return { ok: true, user: recharge.user, balanceCents: recharge.user.balanceCents }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } }; const handleWechatNotify = async ({ headers, body }) => { const { transaction } = wechatPay.verifyNotify({ headers, body }); const outTradeNo = transaction?.out_trade_no; if (!outTradeNo) { throw new Error('回调缺少商户订单号'); } const order = await getOrderByOutTradeNo(outTradeNo); if (!order) { throw new Error('订单不存在'); } return fulfillOrder(order, transaction); }; const getOrderForUser = async (userId, orderId) => { const order = await getOrderById(orderId); if (!order || order.userId !== userId) return null; if (order.status === 'pending' && order.expireAt <= Date.now()) { await pool.query( `UPDATE h5_payment_orders SET status = 'expired', updated_at = ? WHERE id = ? AND status = 'pending'`, [Date.now(), orderId], ); return { ...order, status: 'expired' }; } return order; }; return { config, getBillingConfig, createOrder, getOrderForUser, handleWechatNotify, fulfillOrder, getOrderByOutTradeNo, }; }