From f818dd0307331b4791427b8ec0e63575144706b1 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 16 Jun 2026 16:20:04 +0800 Subject: [PATCH] Add WeChat in-app JSAPI recharge for g2.tkmind.cn. Use JSAPI with bound openid inside WeChat instead of QR long-press, and include related auth redirect and admin UX fixes. Co-authored-by: Cursor --- .env.example | 4 ++ billing-recharge.mjs | 27 +++++++++++- db.mjs | 5 +++ schema.sql | 2 +- server.mjs | 4 +- src/App.tsx | 17 +++++--- src/admin/pages/UsersPage.tsx | 2 + src/api/client.ts | 26 ++++++++--- src/components/RechargeModal.tsx | 29 ++++++++----- src/routes/RequireAdmin.tsx | 16 +++++-- src/types.ts | 12 +++++- src/utils/wechatPay.ts | 49 +++++++++++++++++++++ user-auth.mjs | 12 ++++++ wechat-pay.mjs | 74 ++++++++++++++++++++++++++++++++ wechat-pay.test.mjs | 13 ++++++ 15 files changed, 260 insertions(+), 32 deletions(-) create mode 100644 src/utils/wechatPay.ts diff --git a/.env.example b/.env.example index 18651f4..6b0a786 100644 --- a/.env.example +++ b/.env.example @@ -83,6 +83,10 @@ H5_ACCESS_PASSWORD=change-me # H5_WECHAT_PLATFORM_CERT="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" # # H5_WECHAT_NOTIFY_URL=https://goo.tkmind.cn/webhooks/wechat-pay/notify +# 微信内 JSAPI 支付(服务号网页): +# - 服务号「设置 → 功能设置 → JS接口安全域名」:g2.tkmind.cn +# - 商户平台「产品中心 → 开发配置 → 支付授权目录」:https://g2.tkmind.cn/ +# - 用户须先用同一服务号微信 OAuth 登录(获取 openid)后再充值 # 本地调试验签可临时开启(生产务必关闭): # H5_WECHAT_SKIP_NOTIFY_VERIFY=0 diff --git a/billing-recharge.mjs b/billing-recharge.mjs index 10099ae..ad38956 100644 --- a/billing-recharge.mjs +++ b/billing-recharge.mjs @@ -141,12 +141,34 @@ export function createRechargeService(pool, { userAuth, wechatPay, config = load const outTradeNo = createOutTradeNo(); const expireAt = now + config.orderTtlMs; const description = `TKMind账户充值¥${(amount / 100).toFixed(2)}`; - const mode = payScene === 'h5' ? 'h5' : 'native'; + const mode = + payScene === 'jsapi' ? 'jsapi' : payScene === 'h5' ? 'h5' : 'native'; let codeUrl = null; let h5Url = null; + let jsapiParams = null; try { - if (mode === 'h5') { + 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, @@ -200,6 +222,7 @@ export function createRechargeService(pool, { userAuth, wechatPay, config = load expireAt, codeUrl, h5Url, + jsapiParams, }, }; }; diff --git a/db.mjs b/db.mjs index 801b899..0c11c60 100644 --- a/db.mjs +++ b/db.mjs @@ -250,6 +250,11 @@ export async function migrateSchema(pool) { KEY idx_wechat_pending_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + + await pool.query( + `ALTER TABLE h5_payment_orders + MODIFY pay_mode ENUM('native', 'h5', 'jsapi') NOT NULL DEFAULT 'native'`, + ); } export async function initSchema(pool) { diff --git a/schema.sql b/schema.sql index 4561d11..3b31f95 100644 --- a/schema.sql +++ b/schema.sql @@ -461,7 +461,7 @@ CREATE TABLE IF NOT EXISTS h5_payment_orders ( amount_cents BIGINT NOT NULL, channel ENUM('wechat') NOT NULL DEFAULT 'wechat', status ENUM('pending', 'paid', 'failed', 'expired', 'refunded') NOT NULL DEFAULT 'pending', - pay_mode ENUM('native', 'h5') NOT NULL DEFAULT 'native', + pay_mode ENUM('native', 'h5', 'jsapi') NOT NULL DEFAULT 'native', out_trade_no VARCHAR(64) NOT NULL, provider_txn VARCHAR(128) NULL, code_url VARCHAR(512) NULL, diff --git a/server.mjs b/server.mjs index 1fbb9cd..cf4beeb 100644 --- a/server.mjs +++ b/server.mjs @@ -1097,7 +1097,9 @@ app.post('/auth/billing/recharge-orders', jsonBody, async (req, res) => { const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); const amountCents = Number(req.body?.amountCents); - const payScene = req.body?.payScene === 'h5' ? 'h5' : 'native'; + const payScene = ['native', 'h5', 'jsapi'].includes(req.body?.payScene) + ? req.body.payScene + : 'native'; const result = await rechargeService.createOrder({ userId: me.id, amountCents, diff --git a/src/App.tsx b/src/App.tsx index 47e3bfc..49e0236 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -100,11 +100,7 @@ function AuthenticatedApp({ path="/admin" element={ - {user ? ( - - ) : ( - - )} + {user ? : null} } > @@ -143,7 +139,9 @@ export function App() { setUnauthorizedHandler(() => { setAuthed(false); setUser(null); - navigate('/', { replace: true }); + if (window.location.pathname !== '/') { + navigate('/', { replace: true }); + } }); void checkAuth().then((status) => { setLegacyMode(status.mode === 'legacy'); @@ -181,7 +179,12 @@ export function App() { { - const returnTo = new URLSearchParams(window.location.search).get('return_to'); + const params = new URLSearchParams(window.location.search); + const returnTo = + params.get('return_to') ?? + (window.location.pathname.startsWith('/admin') + ? `${window.location.pathname}${window.location.search}` + : null); if (returnTo) { try { const url = new URL(returnTo, window.location.origin); diff --git a/src/admin/pages/UsersPage.tsx b/src/admin/pages/UsersPage.tsx index c1b152d..a295946 100644 --- a/src/admin/pages/UsersPage.tsx +++ b/src/admin/pages/UsersPage.tsx @@ -56,12 +56,14 @@ export function UsersPage() {
setNewUser((s) => ({ ...s, username: e.target.value }))} /> setNewUser((s) => ({ ...s, password: e.target.value }))} /> diff --git a/src/api/client.ts b/src/api/client.ts index 6ad559d..c1020ad 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -111,9 +111,21 @@ async function parseErrorResponse(res: Response) { } let unauthorizedHandler: (() => void) | null = null; +let unauthorizedHandling = false; export function setUnauthorizedHandler(handler: (() => void) | null) { unauthorizedHandler = handler; + if (handler) unauthorizedHandling = false; +} + +export function resetUnauthorizedGuard() { + unauthorizedHandling = false; +} + +function notifyUnauthorized() { + if (!unauthorizedHandler || unauthorizedHandling) return; + unauthorizedHandling = true; + unauthorizedHandler(); } async function portalFetch(path: string, init?: RequestInit): Promise { @@ -131,7 +143,7 @@ async function portalFetch(path: string, init?: RequestInit): Promise { } if (res.status === 401) { - unauthorizedHandler?.(); + notifyUnauthorized(); const text = await res.text().catch(() => ''); throw new ApiError(401, text || '未授权,请重新登录'); } @@ -173,7 +185,7 @@ async function apiFetch(path: string, init?: RequestInit): Promise { } if (res.status === 401) { - unauthorizedHandler?.(); + notifyUnauthorized(); const text = await res.text().catch(() => ''); throw new ApiError(401, text || '未授权,请重新登录'); } @@ -258,6 +270,7 @@ export async function checkAuth(): Promise { const response = await fetch('/auth/status'); if (!response.ok) return { authenticated: false }; const status = (await response.json()) as AuthStatus; + if (status.authenticated) resetUnauthorizedGuard(); if (status.authenticated && status.mode === 'user' && !status.capabilities) { try { const me = await getMe(); @@ -303,7 +316,7 @@ export async function getBillingConfig(): Promise { export async function createRechargeOrder(input: { amountCents: number; - payScene: 'native' | 'h5'; + payScene: 'native' | 'h5' | 'jsapi'; }): Promise { const result = await portalFetch<{ order: RechargeOrder }>('/auth/billing/recharge-orders', { method: 'POST', @@ -565,7 +578,7 @@ export async function fetchPreviewAsset(path: string): Promise { throw new ApiError(0, formatNetworkError(err)); } if (res.status === 401) { - unauthorizedHandler?.(); + notifyUnauthorized(); throw new ApiError(401, '未授权,请重新登录'); } if (!res.ok) { @@ -699,7 +712,7 @@ export async function fetchMindSpacePageDraftPreview( throw new ApiError(0, formatNetworkError(err)); } if (res.status === 401) { - unauthorizedHandler?.(); + notifyUnauthorized(); throw new ApiError(401, '未授权,请重新登录'); } if (!res.ok) { @@ -1178,6 +1191,7 @@ export async function login(username: string, password: string): Promise 微信支付二维码 +

请使用微信扫一扫完成支付

+ {expireLabel &&

订单将于 {expireLabel} 过期

} + + )} + + {order?.payMode === 'jsapi' && ( +

- {isWeChatBrowser() - ? '长按下方二维码,选择「识别图中二维码」完成支付' - : '请使用微信扫一扫完成支付'} + {polling ? '请在微信支付弹窗中完成付款…' : '点击「微信支付」后将直接调起微信收银台'}

{expireLabel &&

订单将于 {expireLabel} 过期

}
diff --git a/src/routes/RequireAdmin.tsx b/src/routes/RequireAdmin.tsx index 654c8e4..417461b 100644 --- a/src/routes/RequireAdmin.tsx +++ b/src/routes/RequireAdmin.tsx @@ -1,4 +1,5 @@ -import { Navigate } from 'react-router-dom'; +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import type { PortalUser } from '../types'; export function RequireAdmin({ @@ -8,8 +9,15 @@ export function RequireAdmin({ user: PortalUser | null; children: React.ReactNode; }) { - if (user?.role !== 'admin') { - return ; - } + const navigate = useNavigate(); + const allowed = user?.role === 'admin'; + + useEffect(() => { + if (!allowed) { + navigate('/', { replace: true }); + } + }, [allowed, navigate]); + + if (!allowed) return null; return children; } diff --git a/src/types.ts b/src/types.ts index de4b631..e4a449a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -143,14 +143,24 @@ export type BillingConfig = { balanceCents: number; }; +export type JsapiPayParams = { + appId: string; + timeStamp: string; + nonceStr: string; + package: string; + signType: 'MD5' | 'RSA'; + paySign: string; +}; + export type RechargeOrder = { id: string; amountCents: number; status: 'pending' | 'paid' | 'failed' | 'expired' | 'refunded'; - payMode: 'native' | 'h5'; + payMode: 'native' | 'h5' | 'jsapi'; expireAt: number; codeUrl?: string | null; h5Url?: string | null; + jsapiParams?: JsapiPayParams | null; }; export type InsufficientBalanceDetails = { diff --git a/src/utils/wechatPay.ts b/src/utils/wechatPay.ts new file mode 100644 index 0000000..8629fe8 --- /dev/null +++ b/src/utils/wechatPay.ts @@ -0,0 +1,49 @@ +import type { JsapiPayParams } from '../types'; + +type WeixinJsBridge = { + invoke( + api: 'getBrandWCPayRequest', + params: JsapiPayParams, + callback: (result: { err_msg?: string }) => void, + ): void; +}; + +declare global { + interface Window { + WeixinJSBridge?: WeixinJsBridge; + } +} + +export function isWeChatBrowser() { + return /MicroMessenger/i.test(navigator.userAgent); +} + +export function invokeWechatJsapiPay(params: JsapiPayParams): Promise<'ok' | 'cancel'> { + return new Promise((resolve, reject) => { + const invoke = () => { + const bridge = window.WeixinJSBridge; + if (!bridge) { + reject(new Error('当前环境不支持微信支付,请在微信内打开')); + return; + } + bridge.invoke('getBrandWCPayRequest', params, (result) => { + const message = result.err_msg ?? ''; + if (message === 'get_brand_wcpay_request:ok') { + resolve('ok'); + return; + } + if (message === 'get_brand_wcpay_request:cancel') { + resolve('cancel'); + return; + } + reject(new Error(message || '微信支付失败')); + }); + }; + + if (typeof window.WeixinJSBridge === 'undefined') { + document.addEventListener('WeixinJSBridgeReady', invoke, { once: true }); + return; + } + invoke(); + }); +} diff --git a/user-auth.mjs b/user-auth.mjs index 2180272..3b25940 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -1648,6 +1648,17 @@ export function createUserAuth(pool, options = {}) { return rows[0] ?? null; }; + const getWechatOpenidForUser = async (userId, appId) => { + const [rows] = await pool.query( + `SELECT openid + FROM h5_user_wechat_identities + WHERE user_id = ? AND app_id = ? + LIMIT 1`, + [userId, appId], + ); + return rows[0]?.openid ?? null; + }; + const bindWechatToUser = async ({ userId, appId, @@ -2118,6 +2129,7 @@ export function createUserAuth(pool, options = {}) { completeWechatBindAccount, getWechatPendingBind, getWechatBindingStatus, + getWechatOpenidForUser, resetPassword, verify, revoke, diff --git a/wechat-pay.mjs b/wechat-pay.mjs index d8b6289..471e2dd 100644 --- a/wechat-pay.mjs +++ b/wechat-pay.mjs @@ -215,6 +215,33 @@ async function wechatV3Request(config, method, path, bodyObj) { return data; } +export function buildV2JsapiParams(config, prepayId) { + const timeStamp = String(Math.floor(Date.now() / 1000)); + const nonceStr = randomNonce(16); + const pkg = `prepay_id=${prepayId}`; + const signType = 'MD5'; + const paySign = signV2Params( + { + appId: config.appId, + timeStamp, + nonceStr, + package: pkg, + signType, + }, + config.apiKey, + ); + return { appId: config.appId, timeStamp, nonceStr, package: pkg, signType, paySign }; +} + +export function buildV3JsapiParams(config, prepayId) { + const timeStamp = String(Math.floor(Date.now() / 1000)); + const nonceStr = randomNonce(16); + const pkg = `prepay_id=${prepayId}`; + const message = `${config.appId}\n${timeStamp}\n${nonceStr}\n${pkg}\n`; + const paySign = signMessage(message, config.privateKey); + return { appId: config.appId, timeStamp, nonceStr, package: pkg, signType: 'RSA', paySign }; +} + function createV2Client(config) { const createNativeOrder = async ({ outTradeNo, description, amountCents, clientIp }) => { const data = await wechatV2Request(config, '/pay/unifiedorder', { @@ -254,6 +281,25 @@ function createV2Client(config) { return { h5Url: data.mweb_url }; }; + const createJsapiOrder = async ({ outTradeNo, description, amountCents, clientIp, openid }) => { + if (!openid) { + throw new Error('JSAPI 支付缺少 openid'); + } + const data = await wechatV2Request(config, '/pay/unifiedorder', { + body: description, + out_trade_no: outTradeNo, + total_fee: String(amountCents), + spbill_create_ip: clientIp || '127.0.0.1', + notify_url: config.notifyUrl, + trade_type: 'JSAPI', + openid, + }); + if (!data.prepay_id) { + throw new Error('微信支付未返回 prepay_id'); + } + return { jsapiParams: buildV2JsapiParams(config, data.prepay_id) }; + }; + const verifyNotify = ({ headers: _headers, body }) => { const bodyText = typeof body === 'string' ? body : String(body ?? ''); const fields = parseXmlFields(bodyText); @@ -279,8 +325,10 @@ function createV2Client(config) { return { enabled: true, apiVersion: 'v2', + appId: config.appId, createNativeOrder, createH5Order, + createJsapiOrder, verifyNotify, }; } @@ -319,6 +367,26 @@ function createV3Client(config) { return { h5Url: data.h5_url }; }; + const createJsapiOrder = async ({ outTradeNo, description, amountCents, clientIp, openid }) => { + if (!openid) { + throw new Error('JSAPI 支付缺少 openid'); + } + const data = await wechatV3Request(config, 'POST', '/v3/pay/transactions/jsapi', { + appid: config.appId, + mchid: config.mchId, + description, + out_trade_no: outTradeNo, + notify_url: config.notifyUrl, + amount: { total: amountCents, currency: 'CNY' }, + payer: { openid }, + scene_info: clientIp ? { payer_client_ip: clientIp } : undefined, + }); + if (!data.prepay_id) { + throw new Error('微信支付未返回 prepay_id'); + } + return { jsapiParams: buildV3JsapiParams(config, data.prepay_id) }; + }; + const verifyNotify = ({ headers, body }) => { const timestamp = headers['wechatpay-timestamp'] ?? headers['Wechatpay-Timestamp']; const nonce = headers['wechatpay-nonce'] ?? headers['Wechatpay-Nonce']; @@ -371,8 +439,10 @@ function createV3Client(config) { return { enabled: true, apiVersion: 'v3', + appId: config.appId, createNativeOrder, createH5Order, + createJsapiOrder, verifyNotify, }; } @@ -382,12 +452,16 @@ export function createWechatPayClient(config) { return { enabled: false, apiVersion: config?.apiVersion ?? null, + appId: config?.appId ?? null, async createNativeOrder() { throw new Error('微信支付未配置'); }, async createH5Order() { throw new Error('微信支付未配置'); }, + async createJsapiOrder() { + throw new Error('微信支付未配置'); + }, verifyNotify() { throw new Error('微信支付未配置'); }, diff --git a/wechat-pay.test.mjs b/wechat-pay.test.mjs index 2bf280f..25b0699 100644 --- a/wechat-pay.test.mjs +++ b/wechat-pay.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + buildV2JsapiParams, buildXml, createWechatPayClient, loadWechatPayConfig, @@ -81,6 +82,18 @@ test('loadWechatPayConfig selects v2 when APIv2 key is configured', () => { } }); +test('buildV2JsapiParams signs prepay package for WeixinJSBridge', () => { + const config = { + appId: 'wxtestappid', + apiKey: 'test-api-v2-key-32chars-long!!!!', + }; + const params = buildV2JsapiParams(config, 'wx201410272009395522657a690389285100'); + assert.equal(params.appId, 'wxtestappid'); + assert.equal(params.package, 'prepay_id=wx201410272009395522657a690389285100'); + assert.equal(params.signType, 'MD5'); + assert.match(params.paySign, /^[0-9A-F]{32}$/); +}); + test('verifyNotify validates v2 callback signature', () => { const apiKey = 'test-api-v2-key-32chars-long!!!!'; const fields = {