支付时检测是否已经微信授权
This commit is contained in:
@@ -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}`)}`;
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import type { WechatLoginResult } from '@dukang/shared-types';
|
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 { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const returnTo = searchParams.get('return') || '/';
|
||||||
const [phone, setPhone] = useState('13800000001');
|
const [phone, setPhone] = useState('13800000001');
|
||||||
const [code, setCode] = useState('123456');
|
const [code, setCode] = useState('123456');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -43,7 +45,7 @@ export default function LoginPage() {
|
|||||||
phoneVerified: !!result.phoneVerified,
|
phoneVerified: !!result.phoneVerified,
|
||||||
user: result.user as never,
|
user: result.user as never,
|
||||||
});
|
});
|
||||||
navigate('/');
|
navigate(returnTo.startsWith('/') ? returnTo : '/');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +113,7 @@ export default function LoginPage() {
|
|||||||
body: JSON.stringify({ phone, code }),
|
body: JSON.stringify({ phone, code }),
|
||||||
});
|
});
|
||||||
saveSession(data);
|
saveSession(data);
|
||||||
navigate('/');
|
navigate(returnTo.startsWith('/') ? returnTo : '/');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import type { WechatPayOrderResult } from '@dukang/shared-types';
|
import type { WechatLoginResult, WechatPayOrderResult } from '@dukang/shared-types';
|
||||||
import SubPageHeader from '../components/SubPageHeader';
|
import SubPageHeader from '../components/SubPageHeader';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { buildOrderConfirmUrl } from '../lib/navigation';
|
import { buildOrderConfirmUrl } from '../lib/navigation';
|
||||||
|
import {
|
||||||
|
authorizeWechatForPay,
|
||||||
|
buildLoginReturnUrl,
|
||||||
|
fetchClientConfig,
|
||||||
|
fetchUserProfile,
|
||||||
|
isWechatAuthRequiredError,
|
||||||
|
needsWechatAuthForPay,
|
||||||
|
saveWechatLoginResult,
|
||||||
|
} from '../lib/pay-wechat';
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||||
|
|
||||||
function sleep(ms: number) {
|
function sleep(ms: number) {
|
||||||
@@ -21,10 +30,54 @@ async function waitOrderPaid(orderId: string, maxAttempts = 15) {
|
|||||||
|
|
||||||
export default function PayPage() {
|
export default function PayPage() {
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
|
const location = useLocation();
|
||||||
const orderId = params.get('orderId') || '';
|
const orderId = params.get('orderId') || '';
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [authLoading, setAuthLoading] = useState(false);
|
||||||
const [mockMode, setMockMode] = useState(true);
|
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() {
|
function goBackConfirm() {
|
||||||
navigate(
|
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() {
|
async function pay() {
|
||||||
|
if (needsWechatAuth) {
|
||||||
|
setMsg('请先完成微信授权后再支付');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
const result = await request<WechatPayOrderResult>('USER_H5', `/trade/orders/${orderId}/pay`, {
|
const result = await request<WechatPayOrderResult>('USER_H5', `/trade/orders/${orderId}/pay`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -58,7 +133,12 @@ export default function PayPage() {
|
|||||||
|
|
||||||
navigate('/orders?tab=pending_ship');
|
navigate('/orders?tab=pending_ship');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : '支付失败');
|
if (isWechatAuthRequiredError(e)) {
|
||||||
|
setNeedsWechatAuth(true);
|
||||||
|
setMsg('微信支付需要先完成微信授权');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMsg(e instanceof Error ? e.message : '支付失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -72,18 +152,43 @@ export default function PayPage() {
|
|||||||
<span className="material-symbols-outlined">account_balance_wallet</span>
|
<span className="material-symbols-outlined">account_balance_wallet</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="headline-lg text-primary">
|
<p className="headline-lg text-primary">
|
||||||
{mockMode ? (isWechatEnv() ? '微信支付' : 'Mock 微信支付') : '微信支付'}
|
{needsWechatAuth ? '授权微信后可支付' : mockMode ? (isWechatEnv() ? '微信支付' : 'Mock 微信支付') : '微信支付'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted body-md" style={{ marginTop: 8 }}>
|
<p className="text-muted body-md" style={{ marginTop: 8 }}>
|
||||||
{mockMode
|
{needsWechatAuth
|
||||||
? 'preV1 Mock 模式:点击确认即完成;开启真实支付后将调起微信收银台'
|
? '使用微信支付前,需先授权微信账号以完成付款'
|
||||||
: '请在微信内完成支付,支付成功后自动跳转'}
|
: mockMode
|
||||||
|
? 'preV1 Mock 模式:点击确认即完成;开启真实支付后将调起微信收银台'
|
||||||
|
: '请在微信内完成支付,支付成功后自动跳转'}
|
||||||
</p>
|
</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>
|
<p className="label-md text-muted" style={{ marginTop: 24 }}>订单号 {orderId}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-actions">
|
<div className="page-actions">
|
||||||
<button type="button" className="btn btn-primary btn-block" disabled={loading || !orderId} onClick={pay}>
|
<button
|
||||||
{loading ? '支付中...' : '确认支付'}
|
type="button"
|
||||||
|
className="btn btn-primary btn-block"
|
||||||
|
disabled={loading || !orderId || needsWechatAuth}
|
||||||
|
onClick={pay}
|
||||||
|
>
|
||||||
|
{loading ? '支付中...' : needsWechatAuth ? '请先授权微信' : '确认支付'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1157,6 +1157,42 @@
|
|||||||
font-size: 40px;
|
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 {
|
.success-icon {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
|
|||||||
Generated
+13
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "dukang-haoke",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "dukang-haoke",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,14 @@ export type WechatPayOrderResult =
|
|||||||
| { mode: 'mock'; externalNo: string; order?: Record<string, unknown> }
|
| { mode: 'mock'; externalNo: string; order?: Record<string, unknown> }
|
||||||
| { mode: 'jsapi'; prepay: WechatJsapiPrepayParams; orderId: string };
|
| { mode: 'jsapi'; prepay: WechatJsapiPrepayParams; orderId: string };
|
||||||
|
|
||||||
|
/** 业务错误码:微信支付前需完成微信授权 */
|
||||||
|
export const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED';
|
||||||
|
|
||||||
|
export type ClientRuntimeConfig = {
|
||||||
|
mockPay: boolean;
|
||||||
|
wechatPayEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export interface WechatLoginResult {
|
export interface WechatLoginResult {
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
refreshToken?: 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 { TicketController } from './ticket.controller';
|
||||||
import { ThirdPartyLogController } from './third-party-log.controller';
|
import { ThirdPartyLogController } from './third-party-log.controller';
|
||||||
import { WechatController } from './wechat.controller';
|
import { WechatController } from './wechat.controller';
|
||||||
|
import { ClientConfigController } from './client-config.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IamModule, IntegrationsModule],
|
imports: [IamModule, IntegrationsModule],
|
||||||
controllers: [ResourceController, EventController, TicketController, ThirdPartyLogController, WechatController],
|
controllers: [
|
||||||
|
ResourceController,
|
||||||
|
EventController,
|
||||||
|
TicketController,
|
||||||
|
ThirdPartyLogController,
|
||||||
|
WechatController,
|
||||||
|
ClientConfigController,
|
||||||
|
],
|
||||||
providers: [ResourceService, EventService, TicketService, ThirdPartyLogService],
|
providers: [ResourceService, EventService, TicketService, ThirdPartyLogService],
|
||||||
exports: [ResourceService, EventService, TicketService],
|
exports: [ResourceService, EventService, TicketService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
orderTabToStatuses,
|
orderTabToStatuses,
|
||||||
validateMinPurchase,
|
validateMinPurchase,
|
||||||
} from '@dukang/domain';
|
} from '@dukang/domain';
|
||||||
|
import { loadAppConfig, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { BenefitService } from '../benefit/benefit.service';
|
import { BenefitService } from '../benefit/benefit.service';
|
||||||
@@ -169,6 +170,10 @@ export class TradeService {
|
|||||||
|
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
const openId = user?.wxOpenId ?? undefined;
|
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);
|
const payResult = await this.payProvider.payOrder(orderId, openId);
|
||||||
|
|
||||||
if (payResult.mode === 'jsapi') {
|
if (payResult.mode === 'jsapi') {
|
||||||
|
|||||||
Reference in New Issue
Block a user