From f7fe4597c6e9420fe5e83053c159c89b6c12e6e5 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Thu, 2 Jul 2026 17:34:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E4=BB=98=E6=97=B6=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E6=98=AF=E5=90=A6=E5=B7=B2=E7=BB=8F=E5=BE=AE=E4=BF=A1=E6=8E=88?= =?UTF-8?q?=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/h5-user/src/lib/pay-wechat.ts | 48 +++++++ apps/h5-user/src/pages/LoginPage.tsx | 10 +- apps/h5-user/src/pages/PayPage.tsx | 125 ++++++++++++++++-- apps/h5-user/src/styles.css | 36 +++++ package-lock.json | 13 ++ packages/shared-types/src/wechat.ts | 8 ++ .../common/client-config.controller.ts | 14 ++ .../src/modules/common/common.module.ts | 10 +- .../src/modules/trade/trade.service.ts | 5 + 9 files changed, 254 insertions(+), 15 deletions(-) create mode 100644 apps/h5-user/src/lib/pay-wechat.ts create mode 100644 package-lock.json create mode 100644 server/dukang-api/src/modules/common/client-config.controller.ts diff --git a/apps/h5-user/src/lib/pay-wechat.ts b/apps/h5-user/src/lib/pay-wechat.ts new file mode 100644 index 0000000..c1cc6a6 --- /dev/null +++ b/apps/h5-user/src/lib/pay-wechat.ts @@ -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 { + return request('USER_H5', '/common/client-config'); +} + +export async function fetchUserProfile(): Promise { + return request('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 { + if (!isWechatEnv()) { + throw new Error('请在微信内打开以完成授权'); + } + return weixinSdk.login(); +} + +export function buildLoginReturnUrl(pathname: string, search: string) { + return `/login?return=${encodeURIComponent(`${pathname}${search}`)}`; +} diff --git a/apps/h5-user/src/pages/LoginPage.tsx b/apps/h5-user/src/pages/LoginPage.tsx index 21262ba..12441f4 100644 --- a/apps/h5-user/src/pages/LoginPage.tsx +++ b/apps/h5-user/src/pages/LoginPage.tsx @@ -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 { diff --git a/apps/h5-user/src/pages/PayPage.tsx b/apps/h5-user/src/pages/PayPage.tsx index 9207e56..9c720dd 100644 --- a/apps/h5-user/src/pages/PayPage.tsx +++ b/apps/h5-user/src/pages/PayPage.tsx @@ -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('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() { account_balance_wallet

- {mockMode ? (isWechatEnv() ? '微信支付' : 'Mock 微信支付') : '微信支付'} + {needsWechatAuth ? '授权微信后可支付' : mockMode ? (isWechatEnv() ? '微信支付' : 'Mock 微信支付') : '微信支付'}

- {mockMode - ? 'preV1 Mock 模式:点击确认即完成;开启真实支付后将调起微信收银台' - : '请在微信内完成支付,支付成功后自动跳转'} + {needsWechatAuth + ? '使用微信支付前,需先授权微信账号以完成付款' + : mockMode + ? 'preV1 Mock 模式:点击确认即完成;开启真实支付后将调起微信收银台' + : '请在微信内完成支付,支付成功后自动跳转'}

+ + {needsWechatAuth && ( +
+

尚未授权微信

+

授权后可安全调起微信支付,不会重复扣款

+ +
+ )} + + {msg &&

{msg}

}

订单号 {orderId}

-
diff --git a/apps/h5-user/src/styles.css b/apps/h5-user/src/styles.css index 166bdba..daaeb2e 100644 --- a/apps/h5-user/src/styles.css +++ b/apps/h5-user/src/styles.css @@ -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; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..11f019c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "dukang-haoke", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dukang-haoke", + "engines": { + "node": ">=20" + } + } + } +} diff --git a/packages/shared-types/src/wechat.ts b/packages/shared-types/src/wechat.ts index 77bf972..c020aa5 100644 --- a/packages/shared-types/src/wechat.ts +++ b/packages/shared-types/src/wechat.ts @@ -21,6 +21,14 @@ export type WechatPayOrderResult = | { mode: 'mock'; externalNo: string; order?: Record } | { 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; diff --git a/server/dukang-api/src/modules/common/client-config.controller.ts b/server/dukang-api/src/modules/common/client-config.controller.ts new file mode 100644 index 0000000..019ccd2 --- /dev/null +++ b/server/dukang-api/src/modules/common/client-config.controller.ts @@ -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, + }; + } +} diff --git a/server/dukang-api/src/modules/common/common.module.ts b/server/dukang-api/src/modules/common/common.module.ts index ff1f7c0..b58a5df 100644 --- a/server/dukang-api/src/modules/common/common.module.ts +++ b/server/dukang-api/src/modules/common/common.module.ts @@ -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], }) diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 4440e2a..7dfae8c 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -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') {