支付时检测是否已经微信授权

This commit is contained in:
2026-07-02 17:34:58 +08:00
parent a0f4d8c18f
commit f7fe4597c6
9 changed files with 254 additions and 15 deletions
+48
View File
@@ -0,0 +1,48 @@
import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-types';
import { isWechatEnv, weixinSdk } from './weixin';
import { request, saveSession, type UserProfile } from './api';
const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED';
export function isWechatAuthRequiredError(err: unknown): boolean {
return err instanceof Error && err.message === WECHAT_AUTH_REQUIRED;
}
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
return request<ClientRuntimeConfig>('USER_H5', '/common/client-config');
}
export async function fetchUserProfile(): Promise<UserProfile> {
return request<UserProfile>('USER_H5', '/auth/me');
}
/** 真实微信支付且未绑定微信时需要授权 */
export function needsWechatAuthForPay(
config: ClientRuntimeConfig,
profile: UserProfile | null,
): boolean {
return !config.mockPay && config.wechatPayEnabled && isWechatEnv() && !profile?.hasWechat;
}
export function saveWechatLoginResult(result: WechatLoginResult): boolean {
if (!result.accessToken) return false;
saveSession({
accessToken: result.accessToken,
refreshToken: result.refreshToken ?? '',
deviceKey: result.deviceKey,
phoneVerified: !!result.phoneVerified,
user: result.user as never,
});
return true;
}
export async function authorizeWechatForPay(): Promise<WechatLoginResult | void> {
if (!isWechatEnv()) {
throw new Error('请在微信内打开以完成授权');
}
return weixinSdk.login();
}
export function buildLoginReturnUrl(pathname: string, search: string) {
return `/login?return=${encodeURIComponent(`${pathname}${search}`)}`;
}
+6 -4
View File
@@ -1,13 +1,15 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import type { WechatLoginResult } from '@dukang/shared-types';
import { request, saveSession } from '../lib/api';
import { request, saveSession, type UserProfile } from '../lib/api';
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
export default function LoginPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const returnTo = searchParams.get('return') || '/';
const [phone, setPhone] = useState('13800000001');
const [code, setCode] = useState('123456');
const [loading, setLoading] = useState(false);
@@ -43,7 +45,7 @@ export default function LoginPage() {
phoneVerified: !!result.phoneVerified,
user: result.user as never,
});
navigate('/');
navigate(returnTo.startsWith('/') ? returnTo : '/');
}
}
@@ -111,7 +113,7 @@ export default function LoginPage() {
body: JSON.stringify({ phone, code }),
});
saveSession(data);
navigate('/');
navigate(returnTo.startsWith('/') ? returnTo : '/');
} catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败');
} finally {
+113 -8
View File
@@ -1,9 +1,18 @@
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import type { WechatPayOrderResult } from '@dukang/shared-types';
import { useCallback, useEffect, useState } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import type { WechatLoginResult, WechatPayOrderResult } from '@dukang/shared-types';
import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
import { buildOrderConfirmUrl } from '../lib/navigation';
import {
authorizeWechatForPay,
buildLoginReturnUrl,
fetchClientConfig,
fetchUserProfile,
isWechatAuthRequiredError,
needsWechatAuthForPay,
saveWechatLoginResult,
} from '../lib/pay-wechat';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
function sleep(ms: number) {
@@ -21,10 +30,54 @@ async function waitOrderPaid(orderId: string, maxAttempts = 15) {
export default function PayPage() {
const [params] = useSearchParams();
const location = useLocation();
const orderId = params.get('orderId') || '';
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [mockMode, setMockMode] = useState(true);
const [needsWechatAuth, setNeedsWechatAuth] = useState(false);
const [msg, setMsg] = useState('');
const refreshPayReadiness = useCallback(async () => {
try {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
setMockMode(config.mockPay);
setNeedsWechatAuth(needsWechatAuthForPay(config, profile));
return profile;
} catch {
return null;
}
}, []);
const handleWechatLoginResult = useCallback(
async (result: WechatLoginResult) => {
if (result.needBindPhone && result.wxSessionKey) {
setMsg('微信授权成功,请先绑定手机号');
navigate(buildLoginReturnUrl(location.pathname, location.search));
return;
}
if (saveWechatLoginResult(result)) {
setMsg('');
await refreshPayReadiness();
}
},
[location.pathname, location.search, navigate, refreshPayReadiness],
);
useEffect(() => {
refreshPayReadiness();
}, [refreshPayReadiness]);
useEffect(() => {
if (!isWechatEnv()) return;
weixinSdk
.handleOAuthCallback()
.then((result) => {
if (result) void handleWechatLoginResult(result);
})
.catch((e) => setMsg(e instanceof Error ? e.message : '微信授权失败'));
}, [handleWechatLoginResult]);
function goBackConfirm() {
navigate(
@@ -37,8 +90,30 @@ export default function PayPage() {
);
}
async function wechatAuthorize() {
setAuthLoading(true);
setMsg('');
try {
if (!isWechatEnv()) {
setMsg('请在微信内打开以授权微信支付');
return;
}
const result = await authorizeWechatForPay();
if (result) await handleWechatLoginResult(result);
} catch (e) {
setMsg(e instanceof Error ? e.message : '微信授权失败');
} finally {
setAuthLoading(false);
}
}
async function pay() {
if (needsWechatAuth) {
setMsg('请先完成微信授权后再支付');
return;
}
setLoading(true);
setMsg('');
try {
const result = await request<WechatPayOrderResult>('USER_H5', `/trade/orders/${orderId}/pay`, {
method: 'POST',
@@ -58,7 +133,12 @@ export default function PayPage() {
navigate('/orders?tab=pending_ship');
} catch (e) {
alert(e instanceof Error ? e.message : '支付失败');
if (isWechatAuthRequiredError(e)) {
setNeedsWechatAuth(true);
setMsg('微信支付需要先完成微信授权');
return;
}
setMsg(e instanceof Error ? e.message : '支付失败');
} finally {
setLoading(false);
}
@@ -72,18 +152,43 @@ export default function PayPage() {
<span className="material-symbols-outlined">account_balance_wallet</span>
</div>
<p className="headline-lg text-primary">
{mockMode ? (isWechatEnv() ? '微信支付' : 'Mock 微信支付') : '微信支付'}
{needsWechatAuth ? '授权微信后可支付' : mockMode ? (isWechatEnv() ? '微信支付' : 'Mock 微信支付') : '微信支付'}
</p>
<p className="text-muted body-md" style={{ marginTop: 8 }}>
{mockMode
{needsWechatAuth
? '使用微信支付前,需先授权微信账号以完成付款'
: mockMode
? 'preV1 Mock 模式:点击确认即完成;开启真实支付后将调起微信收银台'
: '请在微信内完成支付,支付成功后自动跳转'}
</p>
{needsWechatAuth && (
<div className="pay-wechat-auth-card">
<p className="pay-wechat-auth-title"></p>
<p className="pay-wechat-auth-desc"></p>
<button
type="button"
className="login-wechat-btn pay-wechat-auth-btn"
disabled={authLoading}
onClick={wechatAuthorize}
>
<span className="material-symbols-outlined login-wechat-icon">chat</span>
<span>{authLoading ? '授权中...' : '微信一键授权'}</span>
</button>
</div>
)}
{msg && <p className="pay-wechat-auth-msg">{msg}</p>}
<p className="label-md text-muted" style={{ marginTop: 24 }}> {orderId}</p>
</div>
<div className="page-actions">
<button type="button" className="btn btn-primary btn-block" disabled={loading || !orderId} onClick={pay}>
{loading ? '支付中...' : '确认支付'}
<button
type="button"
className="btn btn-primary btn-block"
disabled={loading || !orderId || needsWechatAuth}
onClick={pay}
>
{loading ? '支付中...' : needsWechatAuth ? '请先授权微信' : '确认支付'}
</button>
</div>
</div>
+36
View File
@@ -1157,6 +1157,42 @@
font-size: 40px;
}
.pay-wechat-auth-card {
margin-top: 24px;
padding: 16px;
border-radius: 12px;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
text-align: center;
}
.pay-wechat-auth-title {
font-size: 16px;
font-weight: 600;
color: var(--color-on-surface);
margin: 0 0 8px;
}
.pay-wechat-auth-desc {
font-size: 13px;
color: var(--color-muted, #999);
margin: 0 0 16px;
line-height: 1.5;
}
.pay-wechat-auth-btn {
width: 100%;
max-width: 280px;
margin: 0 auto;
}
.pay-wechat-auth-msg {
margin-top: 16px;
font-size: 13px;
color: var(--color-primary, #a02d30);
line-height: 1.5;
}
.success-icon {
width: 64px;
height: 64px;
+13
View File
@@ -0,0 +1,13 @@
{
"name": "dukang-haoke",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dukang-haoke",
"engines": {
"node": ">=20"
}
}
}
}
+8
View File
@@ -21,6 +21,14 @@ export type WechatPayOrderResult =
| { mode: 'mock'; externalNo: string; order?: Record<string, unknown> }
| { mode: 'jsapi'; prepay: WechatJsapiPrepayParams; orderId: string };
/** 业务错误码:微信支付前需完成微信授权 */
export const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED';
export type ClientRuntimeConfig = {
mockPay: boolean;
wechatPayEnabled: boolean;
};
export interface WechatLoginResult {
accessToken?: string;
refreshToken?: string;
@@ -0,0 +1,14 @@
import { Controller, Get } from '@nestjs/common';
import { loadAppConfig } from '@dukang/shared-types';
@Controller('common')
export class ClientConfigController {
@Get('client-config')
clientConfig() {
const cfg = loadAppConfig();
return {
mockPay: cfg.mockPay,
wechatPayEnabled: cfg.wechatPayEnabled,
};
}
}
@@ -10,10 +10,18 @@ import { EventController } from './event.controller';
import { TicketController } from './ticket.controller';
import { ThirdPartyLogController } from './third-party-log.controller';
import { WechatController } from './wechat.controller';
import { ClientConfigController } from './client-config.controller';
@Module({
imports: [IamModule, IntegrationsModule],
controllers: [ResourceController, EventController, TicketController, ThirdPartyLogController, WechatController],
controllers: [
ResourceController,
EventController,
TicketController,
ThirdPartyLogController,
WechatController,
ClientConfigController,
],
providers: [ResourceService, EventService, TicketService, ThirdPartyLogService],
exports: [ResourceService, EventService, TicketService],
})
@@ -11,6 +11,7 @@ import {
orderTabToStatuses,
validateMinPurchase,
} from '@dukang/domain';
import { loadAppConfig, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { BenefitService } from '../benefit/benefit.service';
@@ -169,6 +170,10 @@ export class TradeService {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
const openId = user?.wxOpenId ?? undefined;
const appConfig = loadAppConfig();
if (!appConfig.mockPay && !openId) {
throw new BadRequestException(WECHAT_AUTH_REQUIRED);
}
const payResult = await this.payProvider.payOrder(orderId, openId);
if (payResult.mode === 'jsapi') {