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 <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-16 16:20:04 +08:00
parent bb70634a96
commit f818dd0307
15 changed files with 260 additions and 32 deletions
+4
View File
@@ -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
+25 -2
View File
@@ -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,
},
};
};
+5
View File
@@ -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) {
+1 -1
View File
@@ -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,
+3 -1
View File
@@ -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,
+10 -7
View File
@@ -100,11 +100,7 @@ function AuthenticatedApp({
path="/admin"
element={
<RequireAdmin user={user}>
{user ? (
<AdminLayout user={user} onLogout={handleLogout} />
) : (
<Navigate to="/" replace />
)}
{user ? <AdminLayout user={user} onLogout={handleLogout} /> : null}
</RequireAdmin>
}
>
@@ -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() {
<AuthView
legacyMode={legacyMode}
onAuth={(nextUser, nextCapabilities, nextSkills) => {
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);
+2
View File
@@ -56,12 +56,14 @@ export function UsersPage() {
<form className="admin-form" onSubmit={handleCreate}>
<input
placeholder="用户名"
autoComplete="username"
value={newUser.username}
onChange={(e) => setNewUser((s) => ({ ...s, username: e.target.value }))}
/>
<input
placeholder="密码"
type="password"
autoComplete="new-password"
value={newUser.password}
onChange={(e) => setNewUser((s) => ({ ...s, password: e.target.value }))}
/>
+20 -6
View File
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
@@ -131,7 +143,7 @@ async function portalFetch<T>(path: string, init?: RequestInit): Promise<T> {
}
if (res.status === 401) {
unauthorizedHandler?.();
notifyUnauthorized();
const text = await res.text().catch(() => '');
throw new ApiError(401, text || '未授权,请重新登录');
}
@@ -173,7 +185,7 @@ async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
}
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<AuthStatus> {
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<BillingConfig> {
export async function createRechargeOrder(input: {
amountCents: number;
payScene: 'native' | 'h5';
payScene: 'native' | 'h5' | 'jsapi';
}): Promise<RechargeOrder> {
const result = await portalFetch<{ order: RechargeOrder }>('/auth/billing/recharge-orders', {
method: 'POST',
@@ -565,7 +578,7 @@ export async function fetchPreviewAsset(path: string): Promise<string> {
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<PortalU
if (!response.ok) {
throw new ApiError(response.status, body?.message ?? '登录失败');
}
resetUnauthorizedGuard();
if (body?.user) {
try {
const me = await getMe();
@@ -1734,7 +1748,7 @@ export function subscribeSessionEvents(
});
if (res.status === 401) {
unauthorizedHandler?.();
notifyUnauthorized();
throw new ApiError(401, 'SSE 未授权');
}
+19 -10
View File
@@ -3,18 +3,14 @@ import { createPortal } from 'react-dom';
import QRCode from 'qrcode';
import { createRechargeOrder, getBillingConfig, getRechargeOrder } from '../api/client';
import type { BillingConfig, RechargeOrder } from '../types';
import { invokeWechatJsapiPay, isWeChatBrowser } from '../utils/wechatPay';
function formatYuan(cents: number) {
return `¥${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2)}`;
}
function isWeChatBrowser() {
return /MicroMessenger/i.test(navigator.userAgent);
}
function detectPayScene(): 'native' | 'h5' {
// 微信内置浏览器走扫码:H5 收银台需商户单独开通,长按识别二维码即可支付
if (isWeChatBrowser()) return 'native';
function detectPayScene(): 'native' | 'h5' | 'jsapi' {
if (isWeChatBrowser()) return 'jsapi';
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) ? 'h5' : 'native';
}
@@ -156,6 +152,14 @@ export function RechargeModal({
try {
const created = await createRechargeOrderWithFallback(selectedCents);
setOrder(created);
if (created.payMode === 'jsapi' && created.jsapiParams) {
startPolling(created.id);
const result = await invokeWechatJsapiPay(created.jsapiParams);
if (result === 'cancel') {
setError('已取消支付,可重新发起');
}
return;
}
if (created.payMode === 'h5' && created.h5Url && !redirectedRef.current) {
redirectedRef.current = true;
window.location.href = created.h5Url;
@@ -236,10 +240,15 @@ export function RechargeModal({
{order?.payMode === 'native' && qrDataUrl && (
<div className="recharge-qr-panel">
<img src={qrDataUrl} alt="微信支付二维码" className="recharge-qr" />
<p className="recharge-muted">使</p>
{expireLabel && <p className="recharge-muted"> {expireLabel} </p>}
</div>
)}
{order?.payMode === 'jsapi' && (
<div className="recharge-h5-panel">
<p className="recharge-muted">
{isWeChatBrowser()
? '长按下方二维码,选择「识别图中二维码」完成支付'
: '请使用微信扫一扫完成支付'}
{polling ? '请在微信支付弹窗中完成付款…' : '点击「微信支付」后将直接调起微信收银台'}
</p>
{expireLabel && <p className="recharge-muted"> {expireLabel} </p>}
</div>
+12 -4
View File
@@ -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 <Navigate to="/" replace />;
}
const navigate = useNavigate();
const allowed = user?.role === 'admin';
useEffect(() => {
if (!allowed) {
navigate('/', { replace: true });
}
}, [allowed, navigate]);
if (!allowed) return null;
return children;
}
+11 -1
View File
@@ -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 = {
+49
View File
@@ -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();
});
}
+12
View File
@@ -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,
+74
View File
@@ -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('微信支付未配置');
},
+13
View File
@@ -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 = {