diff --git a/apps/h5-shop/package.json b/apps/h5-shop/package.json index ea6033a..62b7619 100644 --- a/apps/h5-shop/package.json +++ b/apps/h5-shop/package.json @@ -11,6 +11,7 @@ "dependencies": { "@dukang/shared-ui": "workspace:*", "@dukang/shared-types": "workspace:*", + "@dukang/weixin-sdk": "workspace:*", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.0" diff --git a/apps/h5-shop/src/lib/weixin.ts b/apps/h5-shop/src/lib/weixin.ts new file mode 100644 index 0000000..48349a4 --- /dev/null +++ b/apps/h5-shop/src/lib/weixin.ts @@ -0,0 +1,9 @@ +import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk'; + +export const weixinSdk = createWeixinSdk({ + apiBase: '/api/v1', + clientApp: 'SHOP_H5', + getAccessToken: () => localStorage.getItem('accessToken'), +}); + +export { isWechatEnv }; diff --git a/apps/h5-shop/src/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx index 2e6dc85..4ec2df6 100644 --- a/apps/h5-shop/src/pages/HomePage.tsx +++ b/apps/h5-shop/src/pages/HomePage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { isLoggedIn, request } from '../lib/api'; +import { isWechatEnv, weixinSdk } from '../lib/weixin'; function formatMoney(n: number) { return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); @@ -27,6 +28,22 @@ export default function HomePage() { const openTime = String(store?.openTime || '10:00'); const closeTime = String(store?.closeTime || '22:00'); + async function handleScan() { + if (isWechatEnv()) { + try { + await weixinSdk.init(); + const token = await weixinSdk.scanQrCode(); + if (token) { + navigate(`/redeem?token=${encodeURIComponent(token)}`); + return; + } + } catch { + /* fall through to manual redeem page */ + } + } + navigate('/redeem'); + } + return (
@@ -55,7 +72,7 @@ export default function HomePage() {
-

扫码核销

diff --git a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx index 669c122..b6460a3 100644 --- a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx +++ b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { request } from '../lib/api'; function formatAmount(n: number) { @@ -8,6 +8,7 @@ function formatAmount(n: number) { export default function RedeemConfirmPage() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const [token, setToken] = useState(''); const [msg, setMsg] = useState(''); const [loading, setLoading] = useState(false); @@ -20,6 +21,11 @@ export default function RedeemConfirmPage() { .catch(() => setStoreName('当前门店')); }, []); + useEffect(() => { + const scanned = searchParams.get('token'); + if (scanned) setToken(scanned); + }, [searchParams]); + async function confirm() { if (!token.trim()) { setMsg('请在开发者选项中输入核销码'); diff --git a/apps/h5-user/package.json b/apps/h5-user/package.json index dfce210..5865180 100644 --- a/apps/h5-user/package.json +++ b/apps/h5-user/package.json @@ -11,6 +11,7 @@ "dependencies": { "@dukang/shared-types": "workspace:*", "@dukang/shared-ui": "workspace:*", + "@dukang/weixin-sdk": "workspace:*", "element-china-area-data": "^6.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/apps/h5-user/src/lib/client-location.ts b/apps/h5-user/src/lib/client-location.ts index df62dc9..58df920 100644 --- a/apps/h5-user/src/lib/client-location.ts +++ b/apps/h5-user/src/lib/client-location.ts @@ -1,3 +1,6 @@ +import { getWechatLocation } from '@dukang/weixin-sdk'; +import { weixinSdk } from './weixin'; + export type ClientGpsLocation = { province?: string; city?: string; @@ -7,55 +10,15 @@ export type ClientGpsLocation = { address?: string; }; -type WxLocationResult = { - latitude: number; - longitude: number; -}; - -declare global { - interface Window { - wx?: { - getLocation?: (options: { - type?: string; - success?: (res: WxLocationResult) => void; - fail?: () => void; - }) => void; - }; - } -} - /** 尝试获取客户端 GPS(微信优先,其次 H5 Geolocation),失败返回 null 不阻塞下单 */ export async function tryGetClientGpsLocation(): Promise { - if (typeof window !== 'undefined' && window.wx?.getLocation) { - const wxResult = await new Promise((resolve) => { - window.wx!.getLocation!({ - type: 'gcj02', - success: (res) => resolve(res), - fail: () => resolve(null), - }); - }); - if (wxResult) { - return { - latitude: wxResult.latitude, - longitude: wxResult.longitude, - }; - } - } - - if (typeof navigator === 'undefined' || !navigator.geolocation) { - return null; - } - - return new Promise((resolve) => { - navigator.geolocation.getCurrentPosition( - (pos) => { - resolve({ - latitude: pos.coords.latitude, - longitude: pos.coords.longitude, - }); - }, - () => resolve(null), - { enableHighAccuracy: false, timeout: 5000, maximumAge: 60_000 }, - ); - }); + const loc = await weixinSdk.getLocation(); + if (!loc) return null; + return { + latitude: loc.latitude, + longitude: loc.longitude, + }; } + +/** @deprecated 使用 tryGetClientGpsLocation */ +export { getWechatLocation }; diff --git a/apps/h5-user/src/lib/weixin.ts b/apps/h5-user/src/lib/weixin.ts new file mode 100644 index 0000000..f957657 --- /dev/null +++ b/apps/h5-user/src/lib/weixin.ts @@ -0,0 +1,11 @@ +import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk'; + +const CLIENT_APP = 'USER_H5'; + +export const weixinSdk = createWeixinSdk({ + apiBase: '/api/v1', + clientApp: CLIENT_APP, + getAccessToken: () => localStorage.getItem('accessToken'), +}); + +export { isWechatEnv }; diff --git a/apps/h5-user/src/pages/LoginPage.tsx b/apps/h5-user/src/pages/LoginPage.tsx index 44d7e63..21262ba 100644 --- a/apps/h5-user/src/pages/LoginPage.tsx +++ b/apps/h5-user/src/pages/LoginPage.tsx @@ -1,8 +1,10 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useNavigate } 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 { normalizePhoneInput, validateMobilePhone } from '../lib/phone'; +import { isWechatEnv, weixinSdk } from '../lib/weixin'; export default function LoginPage() { const navigate = useNavigate(); @@ -12,6 +14,38 @@ export default function LoginPage() { const [agreed, setAgreed] = useState(true); const [codeCooldown, setCodeCooldown] = useState(0); const [msg, setMsg] = useState(''); + const [wxSessionKey, setWxSessionKey] = useState(null); + const [bindMode, setBindMode] = useState(false); + + useEffect(() => { + if (!isWechatEnv()) return; + weixinSdk + .handleOAuthCallback() + .then((result) => { + if (!result) return; + handleWechatLoginResult(result); + }) + .catch((e) => setMsg(e instanceof Error ? e.message : '微信登录失败')); + }, []); + + function handleWechatLoginResult(result: WechatLoginResult) { + if (result.needBindPhone && result.wxSessionKey) { + setBindMode(true); + setWxSessionKey(result.wxSessionKey); + setMsg('微信授权成功,请绑定手机号完成登录'); + return; + } + if (result.accessToken) { + saveSession({ + accessToken: result.accessToken, + refreshToken: result.refreshToken ?? '', + deviceKey: result.deviceKey, + phoneVerified: !!result.phoneVerified, + user: result.user as never, + }); + navigate('/'); + } + } function ensureAgreed() { if (!agreed) { @@ -31,7 +65,7 @@ export default function LoginPage() { setMsg(''); await request('USER_H5', '/auth/sms/send', { method: 'POST', - body: JSON.stringify({ phone, scene: 'USER_LOGIN' }), + body: JSON.stringify({ phone, scene: bindMode ? 'BIND_PHONE' : 'USER_LOGIN' }), }); setMsg('验证码已发送(Mock: 123456)'); setCodeCooldown(60); @@ -60,6 +94,14 @@ export default function LoginPage() { setLoading(true); setMsg(''); try { + if (bindMode && wxSessionKey) { + const data = await request('USER_H5', '/auth/wechat/bind-phone', { + method: 'POST', + body: JSON.stringify({ wxSessionKey, phone, code }), + }); + handleWechatLoginResult(data); + return; + } const data = await request<{ accessToken: string; refreshToken: string; @@ -77,9 +119,19 @@ export default function LoginPage() { } } - function wechatLogin() { + async function wechatLogin() { if (!ensureAgreed()) return; - setMsg('preV1:微信一键授权暂未开放,请使用手机验证码登录'); + setMsg(''); + try { + if (!isWechatEnv()) { + setMsg('请在微信内打开以使用微信一键授权'); + return; + } + const result = await weixinSdk.login(); + if (result) handleWechatLoginResult(result); + } catch (e) { + setMsg(e instanceof Error ? e.message : '微信登录失败'); + } } return ( @@ -97,7 +149,7 @@ export default function LoginPage() {
-

手机验证码登录

+

{bindMode ? '绑定手机号' : '手机验证码登录'}

+86 - {loading ? '登录中...' : '登录'} + {loading ? '登录中...' : bindMode ? '绑定并登录' : '登录'}
-
- - 或者 - -
+ {!bindMode && ( + <> +
+ + 或者 + +
- + + + )}
diff --git a/packages/shared-types/src/config.ts b/packages/shared-types/src/config.ts index b4c0027..2e78904 100644 --- a/packages/shared-types/src/config.ts +++ b/packages/shared-types/src/config.ts @@ -4,6 +4,9 @@ export interface AppConfig { mockPay: boolean; mockDeliveryAuto: boolean; autoApproveStore: boolean; + wechatAuthEnabled: boolean; + wechatPayEnabled: boolean; + wxAppId: string; } export function loadAppConfig(env?: Record): AppConfig { @@ -17,5 +20,8 @@ export function loadAppConfig(env?: Record): AppConf mockPay: e.MOCK_PAY !== 'false', mockDeliveryAuto: e.MOCK_DELIVERY_AUTO !== 'false', autoApproveStore: e.AUTO_APPROVE_STORE !== 'false', + wechatAuthEnabled: e.WECHAT_AUTH_ENABLED === 'true', + wechatPayEnabled: e.WECHAT_PAY_ENABLED === 'true' || e.MOCK_PAY === 'false', + wxAppId: e.WX_APP_ID ?? '', }; } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 4973f95..9eadd82 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1,3 +1,4 @@ export * from './enums'; export * from './api'; export * from './config'; +export * from './wechat'; diff --git a/packages/shared-types/src/wechat.ts b/packages/shared-types/src/wechat.ts new file mode 100644 index 0000000..77bf972 --- /dev/null +++ b/packages/shared-types/src/wechat.ts @@ -0,0 +1,45 @@ +/** 微信 JSSDK 初始化参数(后端签名下发) */ +export interface WechatJssdkConfig { + appId: string; + timestamp: number; + nonceStr: string; + signature: string; + jsApiList: string[]; +} + +/** JSAPI 调起支付参数 */ +export interface WechatJsapiPrepayParams { + appId: string; + timeStamp: string; + nonceStr: string; + package: string; + signType: 'RSA' | 'MD5'; + paySign: string; +} + +export type WechatPayOrderResult = + | { mode: 'mock'; externalNo: string; order?: Record } + | { mode: 'jsapi'; prepay: WechatJsapiPrepayParams; orderId: string }; + +export interface WechatLoginResult { + accessToken?: string; + refreshToken?: string; + deviceKey?: string; + actorType?: string; + actorId?: string; + phoneVerified?: boolean; + needBindPhone?: boolean; + wxSessionKey?: string; + user?: Record; + store?: Record; + partner?: Record; +} + +export type WechatLoginPlatform = 'h5' | 'mini'; + +export interface WechatGpsLocation { + latitude: number; + longitude: number; + speed?: number; + accuracy?: number; +} diff --git a/packages/weixin-sdk/package.json b/packages/weixin-sdk/package.json new file mode 100644 index 0000000..71d0dd8 --- /dev/null +++ b/packages/weixin-sdk/package.json @@ -0,0 +1,15 @@ +{ + "name": "@dukang/weixin-sdk", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@dukang/shared-types": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.4.5" + } +} diff --git a/packages/weixin-sdk/src/auth.ts b/packages/weixin-sdk/src/auth.ts new file mode 100644 index 0000000..468951a --- /dev/null +++ b/packages/weixin-sdk/src/auth.ts @@ -0,0 +1,145 @@ +import type { WechatLoginPlatform, WechatLoginResult } from '@dukang/shared-types'; +import { getRuntimePlatform, isWechatBrowser } from './env'; +import type { WeixinSdkConfig } from './types'; + +const OAUTH_STATE_KEY = 'dukang_wx_oauth_state'; + +function randomState() { + return `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; +} + +async function apiRequest( + config: WeixinSdkConfig, + path: string, + options: RequestInit = {}, +): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Client-App': config.clientApp, + ...(options.headers as Record), + }; + const token = config.getAccessToken?.(); + if (token) headers.Authorization = `Bearer ${token}`; + const apiBase = config.apiBase ?? '/api/v1'; + const res = await fetch(`${apiBase}${path}`, { ...options, headers }); + const json = await res.json(); + if (json.code !== 0) throw new Error(json.message || '请求失败'); + return json.data as T; +} + +/** 获取公众号 OAuth 授权跳转 URL */ +export async function getWechatOAuthUrl( + config: WeixinSdkConfig, + redirectUri: string, + scope: 'snsapi_base' | 'snsapi_userinfo' = 'snsapi_userinfo', +): Promise { + const state = randomState(); + sessionStorage.setItem(OAUTH_STATE_KEY, state); + const qs = new URLSearchParams({ redirectUri, scope, state }); + const data = await apiRequest<{ url: string }>(config, `/common/wechat/oauth-url?${qs}`); + return data.url; +} + +/** 发起微信 OAuth 登录(H5 公众号内跳转授权) */ +export async function startWechatOAuthLogin(config: WeixinSdkConfig, redirectUri?: string): Promise { + if (!isWechatBrowser()) { + throw new Error('请在微信内打开'); + } + const uri = redirectUri ?? window.location.href.split('#')[0]; + const url = await getWechatOAuthUrl(config, uri); + window.location.href = url; +} + +/** 小程序 wx.login 获取 code */ +export function getMiniProgramLoginCode(): Promise { + return new Promise((resolve, reject) => { + if (!window.wx?.login) { + reject(new Error('wx.login 不可用')); + return; + } + window.wx.login({ + success: (res) => (res.code ? resolve(res.code) : reject(new Error('未获取到 code'))), + fail: (err) => reject(new Error(err.errMsg || 'wx.login 失败')), + }); + }); +} + +/** 用 code 完成微信登录(自动识别 H5 / 小程序) */ +export async function loginWithWechatCode( + config: WeixinSdkConfig, + code: string, + platform?: WechatLoginPlatform, +): Promise { + const resolvedPlatform = platform ?? (getRuntimePlatform() === 'mini' ? 'mini' : 'h5'); + return apiRequest(config, '/auth/login/wechat', { + method: 'POST', + body: JSON.stringify({ code, platform: resolvedPlatform }), + }); +} + +/** 处理 OAuth 回调 URL 中的 code 参数并登录 */ +export async function handleWechatOAuthCallback( + config: WeixinSdkConfig, + searchParams?: URLSearchParams, +): Promise { + const params = searchParams ?? new URLSearchParams(window.location.search); + const code = params.get('code'); + if (!code) return null; + + const state = params.get('state'); + const saved = sessionStorage.getItem(OAUTH_STATE_KEY); + if (saved && state && saved !== state) { + throw new Error('OAuth state 校验失败'); + } + sessionStorage.removeItem(OAUTH_STATE_KEY); + + return loginWithWechatCode(config, code, 'h5'); +} + +/** 微信首登绑定手机号 */ +export async function bindWechatPhone( + config: WeixinSdkConfig, + payload: { wxSessionKey: string; phone: string; code: string }, +): Promise { + return apiRequest(config, '/auth/wechat/bind-phone', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +/** 获取微信手机号(小程序 getPhoneNumber 返回的 code,需后端解密) */ +export async function getWechatPhoneNumber(config: WeixinSdkConfig): Promise { + const platform = getRuntimePlatform(); + if (platform !== 'mini') { + throw new Error('H5 环境请使用短信验证码绑定手机号'); + } + const phoneCode = await new Promise((resolve, reject) => { + if (!window.wx?.getPhoneNumber) { + reject(new Error('getPhoneNumber 不可用')); + return; + } + window.wx.getPhoneNumber({ + success: (res) => (res.code ? resolve(res.code) : reject(new Error('未获取到手机号 code'))), + fail: (err) => reject(new Error(err.errMsg || '获取手机号失败')), + }); + }); + const data = await apiRequest<{ phone: string }>(config, '/common/wechat/phone-number', { + method: 'POST', + body: JSON.stringify({ code: phoneCode, platform: 'mini' }), + }); + return data.phone; +} + +/** 一键微信登录(自动选择 OAuth 或 wx.login) */ +export async function wechatLogin(config: WeixinSdkConfig): Promise { + const platform = getRuntimePlatform(); + if (platform === 'mini') { + const code = await getMiniProgramLoginCode(); + return loginWithWechatCode(config, code, 'mini'); + } + if (platform === 'wechat-h5') { + await startWechatOAuthLogin(config); + return; + } + throw new Error('请在微信内打开以使用微信登录'); +} diff --git a/packages/weixin-sdk/src/env.ts b/packages/weixin-sdk/src/env.ts new file mode 100644 index 0000000..d6e4882 --- /dev/null +++ b/packages/weixin-sdk/src/env.ts @@ -0,0 +1,18 @@ +/** 是否微信内置浏览器 */ +export function isWechatBrowser(): boolean { + if (typeof navigator === 'undefined') return false; + return /MicroMessenger/i.test(navigator.userAgent); +} + +/** 是否微信小程序 web-view 或独立小程序环境 */ +export function isMiniProgram(): boolean { + if (typeof window === 'undefined') return false; + const ua = navigator.userAgent.toLowerCase(); + return ua.includes('miniprogram') || (window as Window & { __wxjs_environment?: string }).__wxjs_environment === 'miniprogram'; +} + +export function getRuntimePlatform(): 'mini' | 'wechat-h5' | 'browser' { + if (isMiniProgram()) return 'mini'; + if (isWechatBrowser()) return 'wechat-h5'; + return 'browser'; +} diff --git a/packages/weixin-sdk/src/index.ts b/packages/weixin-sdk/src/index.ts new file mode 100644 index 0000000..c7c0b5b --- /dev/null +++ b/packages/weixin-sdk/src/index.ts @@ -0,0 +1,49 @@ +export { isWechatBrowser, isMiniProgram, getRuntimePlatform } from './env'; +export { initWechatJssdk, ensureJssdkReady, isJssdkReady } from './jssdk'; +export { + getWechatLocation, + canUseWechatLocation, + isWechatEnv, +} from './location'; +export { scanQrCode } from './scan'; +export { invokeWechatPay } from './pay'; +export { + getWechatOAuthUrl, + startWechatOAuthLogin, + getMiniProgramLoginCode, + loginWithWechatCode, + handleWechatOAuthCallback, + bindWechatPhone, + getWechatPhoneNumber, + wechatLogin, +} from './auth'; +export type { WeixinSdkConfig, WxApi, MiniProgramWx } from './types'; +export { DEFAULT_JS_API_LIST } from './types'; + +import type { WeixinSdkConfig } from './types'; +import { initWechatJssdk } from './jssdk'; +import { getWechatLocation } from './location'; +import { scanQrCode } from './scan'; +import { invokeWechatPay } from './pay'; +import { + wechatLogin, + handleWechatOAuthCallback, + bindWechatPhone, + getWechatPhoneNumber, +} from './auth'; + +/** 微信 SDK 门面(按端注入 apiBase / clientApp) */ +export function createWeixinSdk(config: WeixinSdkConfig) { + return { + init: () => initWechatJssdk({ ...config, apiBase: config.apiBase ?? '/api/v1' }), + login: () => wechatLogin(config), + handleOAuthCallback: (params?: URLSearchParams) => handleWechatOAuthCallback(config, params), + bindPhone: (payload: { wxSessionKey: string; phone: string; code: string }) => + bindWechatPhone(config, payload), + getPhoneNumber: () => getWechatPhoneNumber(config), + getLocation: () => getWechatLocation(config), + scanQrCode: () => scanQrCode(config), + pay: (prepay: Parameters[0]) => + invokeWechatPay(prepay, { apiBase: config.apiBase, clientApp: config.clientApp }), + }; +} diff --git a/packages/weixin-sdk/src/jssdk.ts b/packages/weixin-sdk/src/jssdk.ts new file mode 100644 index 0000000..1f239fc --- /dev/null +++ b/packages/weixin-sdk/src/jssdk.ts @@ -0,0 +1,84 @@ +import type { WechatJssdkConfig } from '@dukang/shared-types'; +import { DEFAULT_JS_API_LIST } from './types'; + +const JSSDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; + +let scriptPromise: Promise | null = null; +let configured = false; + +function loadScript(): Promise { + if (typeof document === 'undefined') return Promise.reject(new Error('非浏览器环境')); + if (window.wx) return Promise.resolve(); + if (scriptPromise) return scriptPromise; + + scriptPromise = new Promise((resolve, reject) => { + const existing = document.querySelector('script[data-dukang-wx-jssdk]'); + if (existing) { + existing.addEventListener('load', () => resolve()); + existing.addEventListener('error', () => reject(new Error('微信 JSSDK 加载失败'))); + return; + } + const script = document.createElement('script'); + script.src = JSSDK_URL; + script.async = true; + script.dataset.dukangWxJssdk = '1'; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('微信 JSSDK 加载失败')); + document.head.appendChild(script); + }); + return scriptPromise; +} + +async function fetchJssdkConfig(apiBase: string, clientApp: string, url: string, token?: string | null) { + const headers: Record = { 'X-Client-App': clientApp }; + if (token) headers.Authorization = `Bearer ${token}`; + const qs = new URLSearchParams({ url }); + const res = await fetch(`${apiBase}/common/wechat/jssdk-config?${qs}`, { headers }); + const json = await res.json(); + if (json.code !== 0) throw new Error(json.message || '获取 JSSDK 配置失败'); + return json.data as WechatJssdkConfig; +} + +/** 初始化微信 JSSDK(H5 公众号内) */ +export async function initWechatJssdk(options: { + apiBase: string; + clientApp: string; + url?: string; + getAccessToken?: () => string | null; + jsApiList?: string[]; +}): Promise { + const { apiBase, clientApp, getAccessToken } = options; + const pageUrl = options.url ?? (typeof window !== 'undefined' ? window.location.href.split('#')[0] : ''); + await loadScript(); + if (!window.wx) throw new Error('微信 JSSDK 不可用'); + + const config = await fetchJssdkConfig(apiBase, clientApp, pageUrl, getAccessToken?.()); + const jsApiList = options.jsApiList ?? [...DEFAULT_JS_API_LIST]; + + await new Promise((resolve, reject) => { + window.wx!.config({ + ...config, + jsApiList, + debug: false, + }); + window.wx!.ready(() => { + configured = true; + resolve(); + }); + window.wx!.error((err) => reject(new Error(err.errMsg || 'wx.config 失败'))); + }); +} + +export function isJssdkReady(): boolean { + return configured && !!window.wx; +} + +export async function ensureJssdkReady(options: { + apiBase: string; + clientApp: string; + getAccessToken?: () => string | null; +}): Promise { + if (!isJssdkReady()) { + await initWechatJssdk(options); + } +} diff --git a/packages/weixin-sdk/src/location.ts b/packages/weixin-sdk/src/location.ts new file mode 100644 index 0000000..4d68a59 --- /dev/null +++ b/packages/weixin-sdk/src/location.ts @@ -0,0 +1,62 @@ +import type { WechatGpsLocation } from '@dukang/shared-types'; +import { getRuntimePlatform, isWechatBrowser } from './env'; +import { ensureJssdkReady } from './jssdk'; +import type { WeixinSdkConfig } from './types'; + +/** 获取 GPS 定位(微信 JSSDK / 小程序优先,否则 H5 Geolocation) */ +export async function getWechatLocation(config?: WeixinSdkConfig): Promise { + const platform = getRuntimePlatform(); + + if (platform === 'mini' && window.wx?.getLocation) { + return new Promise((resolve) => { + window.wx!.getLocation!({ + type: 'gcj02', + success: (res) => resolve(res), + fail: () => resolve(null), + }); + }); + } + + if (platform === 'wechat-h5' && config) { + try { + await ensureJssdkReady({ + apiBase: config.apiBase ?? '/api/v1', + clientApp: config.clientApp, + getAccessToken: config.getAccessToken, + }); + if (window.wx?.getLocation) { + return new Promise((resolve) => { + window.wx!.getLocation!({ + type: 'gcj02', + success: (res) => resolve(res), + fail: () => resolve(null), + }); + }); + } + } catch { + /* fall through */ + } + } + + if (typeof navigator === 'undefined' || !navigator.geolocation) return null; + return new Promise((resolve) => { + navigator.geolocation.getCurrentPosition( + (pos) => + resolve({ + latitude: pos.coords.latitude, + longitude: pos.coords.longitude, + accuracy: pos.coords.accuracy, + }), + () => resolve(null), + { enableHighAccuracy: false, timeout: 8000, maximumAge: 60_000 }, + ); + }); +} + +export function canUseWechatLocation(): boolean { + return getRuntimePlatform() !== 'browser' || typeof navigator?.geolocation !== 'undefined'; +} + +export function isWechatEnv(): boolean { + return isWechatBrowser() || getRuntimePlatform() === 'mini'; +} diff --git a/packages/weixin-sdk/src/pay.ts b/packages/weixin-sdk/src/pay.ts new file mode 100644 index 0000000..922a045 --- /dev/null +++ b/packages/weixin-sdk/src/pay.ts @@ -0,0 +1,83 @@ +import type { WechatJsapiPrepayParams } from '@dukang/shared-types'; +import { getRuntimePlatform } from './env'; +import { ensureJssdkReady } from './jssdk'; + +function invokeBridgePay(params: WechatJsapiPrepayParams): Promise { + return new Promise((resolve, reject) => { + const bridge = window.WeixinJSBridge; + if (!bridge) { + reject(new Error('WeixinJSBridge 不可用')); + return; + } + bridge.invoke( + 'getBrandWCPayRequest', + { + appId: params.appId, + timeStamp: params.timeStamp, + nonceStr: params.nonceStr, + package: params.package, + signType: params.signType, + paySign: params.paySign, + }, + (res) => { + const msg = res.err_msg ?? ''; + if (msg.includes('ok')) resolve(); + else if (msg.includes('cancel')) reject(new Error('用户取消支付')); + else reject(new Error(msg || '支付失败')); + }, + ); + }); +} + +/** 调起微信支付 */ +export async function invokeWechatPay( + prepay: WechatJsapiPrepayParams, + config?: { apiBase?: string; clientApp?: string }, +): Promise { + const platform = getRuntimePlatform(); + + if (platform === 'mini' && window.wx?.requestPayment) { + return new Promise((resolve, reject) => { + window.wx!.requestPayment!({ + timeStamp: prepay.timeStamp, + nonceStr: prepay.nonceStr, + package: prepay.package, + signType: prepay.signType, + paySign: prepay.paySign, + success: () => resolve(), + fail: (err) => reject(new Error(err.errMsg || '支付失败')), + }); + }); + } + + if (platform === 'wechat-h5') { + if (config?.clientApp) { + await ensureJssdkReady({ + apiBase: config.apiBase ?? '/api/v1', + clientApp: config.clientApp, + }); + } + if (window.wx?.chooseWXPay) { + return new Promise((resolve, reject) => { + window.wx!.chooseWXPay!({ + ...prepay, + success: () => resolve(), + fail: (err) => reject(new Error(err.errMsg || '支付失败')), + cancel: () => reject(new Error('用户取消支付')), + }); + }); + } + if (window.WeixinJSBridge) { + return invokeBridgePay(prepay); + } + if (typeof document !== 'undefined') { + await new Promise((resolve) => { + if (window.WeixinJSBridge) resolve(); + else document.addEventListener('WeixinJSBridgeReady', () => resolve(), { once: true }); + }); + return invokeBridgePay(prepay); + } + } + + throw new Error('当前环境不支持微信支付'); +} diff --git a/packages/weixin-sdk/src/scan.ts b/packages/weixin-sdk/src/scan.ts new file mode 100644 index 0000000..9844df2 --- /dev/null +++ b/packages/weixin-sdk/src/scan.ts @@ -0,0 +1,40 @@ +import { getRuntimePlatform } from './env'; +import { ensureJssdkReady } from './jssdk'; +import type { WeixinSdkConfig } from './types'; + +/** 调起扫码(返回二维码/条码内容,失败返回 null) */ +export async function scanQrCode(config: WeixinSdkConfig): Promise { + const platform = getRuntimePlatform(); + + if (platform === 'mini' && window.wx?.scanCode) { + return new Promise((resolve) => { + window.wx!.scanCode!({ + onlyFromCamera: true, + scanType: ['qrCode', 'barCode'], + success: (res) => resolve(res.result || null), + fail: () => resolve(null), + }); + }); + } + + if (platform === 'wechat-h5') { + await ensureJssdkReady({ + apiBase: config.apiBase ?? '/api/v1', + clientApp: config.clientApp, + getAccessToken: config.getAccessToken, + }); + if (window.wx?.scanQRCode) { + return new Promise((resolve) => { + window.wx!.scanQRCode!({ + needResult: 1, + scanType: ['qrCode', 'barCode'], + success: (res) => resolve(res.resultStr || null), + fail: () => resolve(null), + }); + }); + } + } + + const manual = typeof window !== 'undefined' ? window.prompt('当前环境无法调起微信扫码,请手动输入核销码') : null; + return manual?.trim() || null; +} diff --git a/packages/weixin-sdk/src/types.ts b/packages/weixin-sdk/src/types.ts new file mode 100644 index 0000000..2041494 --- /dev/null +++ b/packages/weixin-sdk/src/types.ts @@ -0,0 +1,85 @@ +import type { WechatGpsLocation, WechatJsapiPrepayParams, WechatJssdkConfig } from '@dukang/shared-types'; + +export type WeixinSdkConfig = { + /** API 根路径,默认 /api/v1 */ + apiBase?: string; + /** X-Client-App 请求头 */ + clientApp: string; + /** 获取 access token(可选,登录后自动带) */ + getAccessToken?: () => string | null; +}; + +export type WxInvokeResult = { + errMsg: string; +} & T; + +export type WxApi = { + config: (options: WechatJssdkConfig & { debug?: boolean }) => void; + ready: (cb: () => void) => void; + error: (cb: (res: { errMsg: string }) => void) => void; + checkJsApi: (options: { jsApiList: string[]; success?: (res: Record) => void }) => void; + getLocation: (options: { + type?: string; + success?: (res: WechatGpsLocation) => void; + fail?: (res: { errMsg: string }) => void; + }) => void; + scanQRCode: (options: { + needResult?: 0 | 1; + scanType?: Array<'qrCode' | 'barCode'>; + success?: (res: { resultStr: string }) => void; + fail?: (res: { errMsg: string }) => void; + }) => void; + chooseWXPay: (options: WechatJsapiPrepayParams & { + success?: () => void; + fail?: (res: { errMsg: string }) => void; + cancel?: () => void; + }) => void; +}; + +export type MiniProgramWx = { + login: (options: { + success?: (res: { code: string }) => void; + fail?: (res: { errMsg: string }) => void; + }) => void; + getPhoneNumber: (options: { + success?: (res: { code: string }) => void; + fail?: (res: { errMsg: string }) => void; + }) => void; + getLocation: WxApi['getLocation']; + scanCode: (options: { + onlyFromCamera?: boolean; + scanType?: Array<'qrCode' | 'barCode'>; + success?: (res: { result: string }) => void; + fail?: (res: { errMsg: string }) => void; + }) => void; + requestPayment: (options: { + timeStamp: string; + nonceStr: string; + package: string; + signType: string; + paySign: string; + success?: () => void; + fail?: (res: { errMsg: string }) => void; + }) => void; +}; + +declare global { + interface Window { + wx?: WxApi & MiniProgramWx; + WeixinJSBridge?: { + invoke: ( + api: string, + params: Record, + cb: (res: { err_msg?: string }) => void, + ) => void; + }; + } +} + +export const DEFAULT_JS_API_LIST = [ + 'getLocation', + 'scanQRCode', + 'chooseWXPay', + 'updateAppMessageShareData', + 'updateTimelineShareData', +] as const; diff --git a/packages/weixin-sdk/tsconfig.json b/packages/weixin-sdk/tsconfig.json new file mode 100644 index 0000000..4a42960 --- /dev/null +++ b/packages/weixin-sdk/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ES2020", "DOM"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa3363b..c0059b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: '@dukang/shared-ui': specifier: workspace:* version: link:../../packages/shared-ui + '@dukang/weixin-sdk': + specifier: workspace:* + version: link:../../packages/weixin-sdk react: specifier: ^18.3.1 version: 18.3.1 @@ -124,6 +127,9 @@ importers: '@dukang/shared-ui': specifier: workspace:* version: link:../../packages/shared-ui + '@dukang/weixin-sdk': + specifier: workspace:* + version: link:../../packages/weixin-sdk element-china-area-data: specifier: ^6.1.0 version: 6.1.0 @@ -180,6 +186,16 @@ importers: specifier: ^5.4.5 version: 5.9.3 + packages/weixin-sdk: + dependencies: + '@dukang/shared-types': + specifier: workspace:* + version: link:../shared-types + devDependencies: + typescript: + specifier: ^5.4.5 + version: 5.9.3 + server/dukang-api: dependencies: '@dukang/domain': diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index 405da35..78abcf5 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -10,4 +10,15 @@ MOCK_DELIVERY_AUTO=true AUTO_APPROVE_STORE=true # 反向代理后提取真实客户端 IP(下单 IP 定位) -# TRUST_PROXY=true +TRUST_PROXY=true + +# 微信SDK(WECHAT_AUTH_ENABLED=true 时生效) +WX_APP_ID= +WX_APP_SECRET= +WECHAT_AUTH_ENABLED=false +WECHAT_PAY_ENABLED=false +WX_MCH_ID= +WX_MCH_SERIAL_NO= +WX_MCH_PRIVATE_KEY= +WX_API_V3_KEY= +WX_PAY_NOTIFY_URL=https://your-domain.com/api/v1/callbacks/wechat/pay \ No newline at end of file diff --git a/server/dukang-api/src/integrations/integrations.constants.ts b/server/dukang-api/src/integrations/integrations.constants.ts index 3b96cfa..e000446 100644 --- a/server/dukang-api/src/integrations/integrations.constants.ts +++ b/server/dukang-api/src/integrations/integrations.constants.ts @@ -1,3 +1,4 @@ export const SMS_PROVIDER = 'SMS_PROVIDER'; export const PAY_PROVIDER = 'PAY_PROVIDER'; export const DELIVERY_PROVIDER = 'DELIVERY_PROVIDER'; +export const WECHAT_PROVIDER = 'WECHAT_PROVIDER'; diff --git a/server/dukang-api/src/integrations/integrations.module.ts b/server/dukang-api/src/integrations/integrations.module.ts index 0e3e27c..267e27f 100644 --- a/server/dukang-api/src/integrations/integrations.module.ts +++ b/server/dukang-api/src/integrations/integrations.module.ts @@ -1,21 +1,50 @@ import { Module } from '@nestjs/common'; import { BullModule } from '@nestjs/bullmq'; +import { loadAppConfig } from '@dukang/shared-types'; import { SmsMockProvider } from './sms/sms.mock.provider'; import { PayMockProvider } from './pay/pay.mock.provider'; +import { PayWechatProvider } from './pay/pay.wechat.provider'; import { DeliveryMockProvider } from './delivery/delivery.mock.provider'; -import { SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER } from './integrations.constants'; +import { WechatApiProvider } from './wechat/wechat.api.provider'; +import { WechatDisabledProvider } from './wechat/wechat.disabled.provider'; +import { + SMS_PROVIDER, + PAY_PROVIDER, + DELIVERY_PROVIDER, + WECHAT_PROVIDER, +} from './integrations.constants'; import { DELIVERY_QUEUE } from '../jobs/jobs.constants'; +import type { IWechatProvider } from './wechat/wechat.interface'; +import type { IPayProvider } from './pay/pay.interface'; @Module({ imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE })], providers: [ { provide: SMS_PROVIDER, useClass: SmsMockProvider }, - { provide: PAY_PROVIDER, useClass: PayMockProvider }, + WechatApiProvider, + WechatDisabledProvider, + { + provide: WECHAT_PROVIDER, + useFactory: (api: WechatApiProvider, disabled: WechatDisabledProvider): IWechatProvider => { + const cfg = loadAppConfig(); + return cfg.wechatAuthEnabled && cfg.wxAppId ? api : disabled; + }, + inject: [WechatApiProvider, WechatDisabledProvider], + }, + PayMockProvider, + PayWechatProvider, + { + provide: PAY_PROVIDER, + useFactory: (mock: PayMockProvider, wechat: PayWechatProvider): IPayProvider => { + const cfg = loadAppConfig(); + return cfg.mockPay ? mock : wechat; + }, + inject: [PayMockProvider, PayWechatProvider], + }, { provide: DELIVERY_PROVIDER, useClass: DeliveryMockProvider }, SmsMockProvider, - PayMockProvider, DeliveryMockProvider, ], - exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER], + exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER], }) export class IntegrationsModule {} diff --git a/server/dukang-api/src/integrations/pay/pay.interface.ts b/server/dukang-api/src/integrations/pay/pay.interface.ts index c2cd164..ae5fb15 100644 --- a/server/dukang-api/src/integrations/pay/pay.interface.ts +++ b/server/dukang-api/src/integrations/pay/pay.interface.ts @@ -1,3 +1,9 @@ +import type { WechatJsapiPrepayParams } from '@dukang/shared-types'; + +export type PayOrderResult = + | { mode: 'mock'; externalNo: string } + | { mode: 'jsapi'; prepay: WechatJsapiPrepayParams }; + export interface IPayProvider { - payOrder(orderId: bigint): Promise<{ externalNo: string }>; + payOrder(orderId: bigint, openId?: string): Promise; } diff --git a/server/dukang-api/src/integrations/pay/pay.mock.provider.ts b/server/dukang-api/src/integrations/pay/pay.mock.provider.ts index a08e216..ce7e8d4 100644 --- a/server/dukang-api/src/integrations/pay/pay.mock.provider.ts +++ b/server/dukang-api/src/integrations/pay/pay.mock.provider.ts @@ -1,15 +1,15 @@ import { Injectable } from '@nestjs/common'; import { loadAppConfig } from '@dukang/shared-types'; -import { IPayProvider } from './pay.interface'; +import type { IPayProvider, PayOrderResult } from './pay.interface'; @Injectable() export class PayMockProvider implements IPayProvider { private readonly config = loadAppConfig(); - async payOrder(_orderId: bigint): Promise<{ externalNo: string }> { + async payOrder(_orderId: bigint, _openId?: string): Promise { if (!this.config.mockPay) { - throw new Error('Real WeChat pay not implemented in preV1'); + throw new Error('Real WeChat pay requires PayWechatProvider'); } - return { externalNo: `MOCK-${Date.now()}` }; + return { mode: 'mock', externalNo: `MOCK-${Date.now()}` }; } } diff --git a/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts b/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts new file mode 100644 index 0000000..acb405d --- /dev/null +++ b/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { loadAppConfig } from '@dukang/shared-types'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { WECHAT_PROVIDER } from '../integrations.constants'; +import type { IWechatProvider } from '../wechat/wechat.interface'; +import type { IPayProvider, PayOrderResult } from './pay.interface'; + +@Injectable() +export class PayWechatProvider implements IPayProvider { + private readonly config = loadAppConfig(); + + constructor( + private readonly prisma: PrismaService, + @Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider, + ) {} + + async payOrder(orderId: bigint, openId?: string): Promise { + if (this.config.mockPay) { + return { mode: 'mock', externalNo: `MOCK-${Date.now()}` }; + } + if (!openId) { + throw new Error('微信支付需要用户 openId,请先完成微信授权登录'); + } + if (!this.wechat.isEnabled()) { + throw new Error('微信能力未启用,请配置 WECHAT_AUTH_ENABLED 与 WX_APP_ID/SECRET'); + } + + const order = await this.prisma.order.findUnique({ where: { id: orderId } }); + if (!order) throw new Error('订单不存在'); + + const amountFen = Math.round(Number(order.payAmount) * 100); + const prepay = await this.wechat.createJsapiPrepay({ + orderNo: order.orderNo, + description: `杜康好客订单 ${order.orderNo}`, + amountFen, + openId, + notifyUrl: process.env.WX_PAY_NOTIFY_URL ?? '', + }); + return { mode: 'jsapi', prepay }; + } +} diff --git a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts new file mode 100644 index 0000000..bd755a7 --- /dev/null +++ b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts @@ -0,0 +1,259 @@ +import { createDecipheriv, createHash, createSign, randomBytes, randomUUID } from 'crypto'; +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; +import { loadAppConfig } from '@dukang/shared-types'; +import { RedisService } from '../../common/redis/redis.service'; +import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './wechat.interface'; + +type TokenCache = { accessToken: string; expiresAt: number }; +type TicketCache = { ticket: string; expiresAt: number }; + +const ACCESS_TOKEN_KEY = 'wechat:access_token'; +const JSAPI_TICKET_KEY = 'wechat:jsapi_ticket'; + +@Injectable() +export class WechatApiProvider implements IWechatProvider { + private readonly logger = new Logger(WechatApiProvider.name); + private readonly config = loadAppConfig(); + private readonly appId = process.env.WX_APP_ID ?? ''; + private readonly appSecret = process.env.WX_APP_SECRET ?? ''; + private readonly mchId = process.env.WX_MCH_ID ?? ''; + private readonly mchSerialNo = process.env.WX_MCH_SERIAL_NO ?? ''; + private readonly mchPrivateKey = (process.env.WX_MCH_PRIVATE_KEY ?? '').replace(/\\n/g, '\n'); + private readonly apiV3Key = process.env.WX_API_V3_KEY ?? ''; + private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? ''; + + constructor(private readonly redis: RedisService) {} + + isEnabled() { + return this.config.wechatAuthEnabled && !!this.appId && !!this.appSecret; + } + + buildOAuthUrl(redirectUri: string, state: string, scope = 'snsapi_userinfo') { + const qs = new URLSearchParams({ + appid: this.appId, + redirect_uri: redirectUri, + response_type: 'code', + scope, + state, + }); + return `https://open.weixin.qq.com/connect/oauth2/authorize?${qs.toString()}#wechat_redirect`; + } + + async code2Session(code: string): Promise { + const url = new URL('https://api.weixin.qq.com/sns/jscode2session'); + url.searchParams.set('appid', this.appId); + url.searchParams.set('secret', this.appSecret); + url.searchParams.set('js_code', code); + url.searchParams.set('grant_type', 'authorization_code'); + const data = await this.fetchJson<{ + openid?: string; + unionid?: string; + session_key?: string; + errcode?: number; + errmsg?: string; + }>(url.toString()); + if (!data.openid) { + throw new InternalServerErrorException(data.errmsg || '微信 code2session 失败'); + } + return { + openId: data.openid, + unionId: data.unionid, + sessionKey: data.session_key, + }; + } + + async oauth2AccessToken(code: string): Promise { + const url = new URL('https://api.weixin.qq.com/sns/oauth2/access_token'); + url.searchParams.set('appid', this.appId); + url.searchParams.set('secret', this.appSecret); + url.searchParams.set('code', code); + url.searchParams.set('grant_type', 'authorization_code'); + const data = await this.fetchJson<{ + openid?: string; + unionid?: string; + access_token?: string; + refresh_token?: string; + errcode?: number; + errmsg?: string; + }>(url.toString()); + if (!data.openid) { + throw new InternalServerErrorException(data.errmsg || '微信 OAuth 失败'); + } + return { + openId: data.openid, + unionId: data.unionid, + accessToken: data.access_token, + refreshToken: data.refresh_token, + }; + } + + async createJssdkConfig(url: string) { + const ticket = await this.getJsapiTicket(); + const nonceStr = randomBytes(8).toString('hex'); + const timestamp = Math.floor(Date.now() / 1000); + const raw = `jsapi_ticket=${ticket}&noncestr=${nonceStr}×tamp=${timestamp}&url=${url}`; + const signature = createHash('sha1').update(raw).digest('hex'); + return { + appId: this.appId, + timestamp, + nonceStr, + signature, + jsApiList: ['getLocation', 'scanQRCode', 'chooseWXPay'], + }; + } + + async getPhoneNumberByCode(code: string, platform: 'mini' | 'h5'): Promise { + if (platform === 'h5') { + throw new InternalServerErrorException('H5 请使用短信绑定手机号'); + } + const accessToken = await this.getAccessToken(); + const url = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`; + const data = await this.fetchJson<{ + errcode?: number; + errmsg?: string; + phone_info?: { phoneNumber?: string; purePhoneNumber?: string }; + }>(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }); + const phone = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber; + if (!phone) { + throw new InternalServerErrorException(data.errmsg || '获取手机号失败'); + } + return phone; + } + + async createJsapiPrepay(params: { + orderNo: string; + description: string; + amountFen: number; + openId: string; + notifyUrl: string; + }) { + if (!this.mchId || !this.mchPrivateKey || !this.apiV3Key) { + throw new InternalServerErrorException('微信支付商户配置不完整'); + } + const notifyUrl = params.notifyUrl || this.notifyUrl; + const body = { + appid: this.appId, + mchid: this.mchId, + description: params.description, + out_trade_no: params.orderNo, + notify_url: notifyUrl, + amount: { total: params.amountFen, currency: 'CNY' }, + payer: { openid: params.openId }, + }; + const path = '/v3/pay/transactions/jsapi'; + const payload = JSON.stringify(body); + const auth = this.signPayRequest('POST', path, payload); + const res = await this.fetchJson<{ prepay_id?: string }>(`https://api.mch.weixin.qq.com${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: auth, + }, + body: payload, + }); + if (!res.prepay_id) { + throw new InternalServerErrorException('微信预支付下单失败'); + } + const timeStamp = String(Math.floor(Date.now() / 1000)); + const nonceStr = randomUUID().replace(/-/g, ''); + const packageStr = `prepay_id=${res.prepay_id}`; + const message = `${this.appId}\n${timeStamp}\n${nonceStr}\n${packageStr}\n`; + const sign = createSign('RSA-SHA256'); + sign.update(message); + sign.end(); + const paySign = sign.sign(this.mchPrivateKey, 'base64'); + return { + appId: this.appId, + timeStamp, + nonceStr, + package: packageStr, + signType: 'RSA' as const, + paySign, + }; + } + + private async getAccessToken(): Promise { + const cached = await this.redis.getJson(ACCESS_TOKEN_KEY); + if (cached && cached.expiresAt > Date.now()) return cached.accessToken; + + const url = new URL('https://api.weixin.qq.com/cgi-bin/token'); + url.searchParams.set('grant_type', 'client_credential'); + url.searchParams.set('appid', this.appId); + url.searchParams.set('secret', this.appSecret); + const data = await this.fetchJson<{ access_token?: string; expires_in?: number; errcode?: number; errmsg?: string }>( + url.toString(), + ); + if (!data.access_token) { + throw new InternalServerErrorException(data.errmsg || '获取 access_token 失败'); + } + const ttl = Math.max((data.expires_in ?? 7200) - 300, 60); + await this.redis.setJson( + ACCESS_TOKEN_KEY, + { accessToken: data.access_token, expiresAt: Date.now() + ttl * 1000 }, + ttl, + ); + return data.access_token; + } + + private async getJsapiTicket(): Promise { + const cached = await this.redis.getJson(JSAPI_TICKET_KEY); + if (cached && cached.expiresAt > Date.now()) return cached.ticket; + + const accessToken = await this.getAccessToken(); + const url = new URL('https://api.weixin.qq.com/cgi-bin/ticket/getticket'); + url.searchParams.set('access_token', accessToken); + url.searchParams.set('type', 'jsapi'); + const data = await this.fetchJson<{ ticket?: string; expires_in?: number; errcode?: number; errmsg?: string }>( + url.toString(), + ); + if (!data.ticket) { + throw new InternalServerErrorException(data.errmsg || '获取 jsapi_ticket 失败'); + } + const ttl = Math.max((data.expires_in ?? 7200) - 300, 60); + await this.redis.setJson( + JSAPI_TICKET_KEY, + { ticket: data.ticket, expiresAt: Date.now() + ttl * 1000 }, + ttl, + ); + return data.ticket; + } + + private signPayRequest(method: string, path: string, body: string) { + const timestamp = Math.floor(Date.now() / 1000); + const nonce = randomUUID(); + const message = `${method}\n${path}\n${timestamp}\n${nonce}\n${body}\n`; + const sign = createSign('RSA-SHA256'); + sign.update(message); + sign.end(); + const signature = sign.sign(this.mchPrivateKey, 'base64'); + return `WECHATPAY2-SHA256-RSA2048 mchid="${this.mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${this.mchSerialNo}"`; + } + + private async fetchJson(url: string, init?: RequestInit): Promise { + const res = await fetch(url, init); + const text = await res.text(); + try { + return JSON.parse(text) as T; + } catch { + this.logger.error(`WeChat API invalid JSON: ${text.slice(0, 200)}`); + throw new InternalServerErrorException('微信接口响应异常'); + } + } + + /** 解密小程序敏感数据(备用) */ + decryptData(sessionKey: string, encryptedData: string, iv: string): Record { + const key = Buffer.from(sessionKey, 'base64'); + const decipher = createDecipheriv('aes-128-cbc', key, Buffer.from(iv, 'base64')); + decipher.setAutoPadding(true); + const decoded = Buffer.concat([ + decipher.update(Buffer.from(encryptedData, 'base64')), + decipher.final(), + ]); + return JSON.parse(decoded.toString('utf8')) as Record; + } +} diff --git a/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts new file mode 100644 index 0000000..198a94d --- /dev/null +++ b/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts @@ -0,0 +1,37 @@ +import { Injectable, NotImplementedException } from '@nestjs/common'; +import type { IWechatProvider } from './wechat.interface'; + +@Injectable() +export class WechatDisabledProvider implements IWechatProvider { + isEnabled() { + return false; + } + + private disabled(): never { + throw new NotImplementedException('FEATURE_DISABLED'); + } + + code2Session() { + return this.disabled(); + } + + oauth2AccessToken() { + return this.disabled(); + } + + createJssdkConfig() { + return this.disabled(); + } + + buildOAuthUrl() { + return this.disabled(); + } + + getPhoneNumberByCode() { + return this.disabled(); + } + + createJsapiPrepay() { + return this.disabled(); + } +} diff --git a/server/dukang-api/src/integrations/wechat/wechat.interface.ts b/server/dukang-api/src/integrations/wechat/wechat.interface.ts new file mode 100644 index 0000000..9b05d11 --- /dev/null +++ b/server/dukang-api/src/integrations/wechat/wechat.interface.ts @@ -0,0 +1,43 @@ +import type { WechatJssdkConfig, WechatJsapiPrepayParams } from '@dukang/shared-types'; + +export type WechatCodeSession = { + openId: string; + unionId?: string; + sessionKey?: string; + accessToken?: string; +}; + +export type WechatOAuthSession = { + openId: string; + unionId?: string; + accessToken?: string; + refreshToken?: string; +}; + +export interface IWechatProvider { + isEnabled(): boolean; + + /** 小程序 code2session */ + code2Session(code: string): Promise; + + /** 公众号 H5 OAuth code 换 openId */ + oauth2AccessToken(code: string): Promise; + + /** JSSDK 签名配置 */ + createJssdkConfig(url: string): Promise; + + /** 构建公众号 OAuth 授权 URL */ + buildOAuthUrl(redirectUri: string, state: string, scope?: string): string; + + /** 小程序手机号 code 解密(或调用微信 getPhoneNumber 接口) */ + getPhoneNumberByCode(code: string, platform: 'mini' | 'h5'): Promise; + + /** 创建 JSAPI 预支付参数 */ + createJsapiPrepay(params: { + orderNo: string; + description: string; + amountFen: number; + openId: string; + notifyUrl: string; + }): Promise; +} diff --git a/server/dukang-api/src/modules/common/common.module.ts b/server/dukang-api/src/modules/common/common.module.ts index 67b3f55..ff1f7c0 100644 --- a/server/dukang-api/src/modules/common/common.module.ts +++ b/server/dukang-api/src/modules/common/common.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { IamModule } from '../iam/iam.module'; +import { IntegrationsModule } from '../../integrations/integrations.module'; import { ResourceService } from './resource.service'; import { EventService } from './event.service'; import { TicketService } from './ticket.service'; @@ -8,10 +9,11 @@ import { ResourceController } from './resource.controller'; import { EventController } from './event.controller'; import { TicketController } from './ticket.controller'; import { ThirdPartyLogController } from './third-party-log.controller'; +import { WechatController } from './wechat.controller'; @Module({ - imports: [IamModule], - controllers: [ResourceController, EventController, TicketController, ThirdPartyLogController], + imports: [IamModule, IntegrationsModule], + controllers: [ResourceController, EventController, TicketController, ThirdPartyLogController, WechatController], providers: [ResourceService, EventService, TicketService, ThirdPartyLogService], exports: [ResourceService, EventService, TicketService], }) diff --git a/server/dukang-api/src/modules/common/wechat.controller.ts b/server/dukang-api/src/modules/common/wechat.controller.ts new file mode 100644 index 0000000..6691df6 --- /dev/null +++ b/server/dukang-api/src/modules/common/wechat.controller.ts @@ -0,0 +1,43 @@ +import { BadRequestException, Body, Controller, Get, Inject, Post, Query } from '@nestjs/common'; +import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { WECHAT_PROVIDER } from '../../integrations/integrations.constants'; +import type { IWechatProvider } from '../../integrations/wechat/wechat.interface'; + +class PhoneNumberDto { + @IsString() + @IsNotEmpty() + code: string; + + @IsString() + @IsIn(['mini', 'h5']) + @IsOptional() + platform?: 'mini' | 'h5'; +} + +@Controller('common/wechat') +export class WechatController { + constructor(@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider) {} + + @Get('jssdk-config') + async jssdkConfig(@Query('url') url: string) { + if (!url) throw new BadRequestException('url 参数必填'); + return this.wechat.createJssdkConfig(url); + } + + @Get('oauth-url') + oauthUrl( + @Query('redirectUri') redirectUri: string, + @Query('state') state: string, + @Query('scope') scope?: string, + ) { + if (!redirectUri || !state) throw new BadRequestException('redirectUri 与 state 必填'); + return { url: this.wechat.buildOAuthUrl(redirectUri, state, scope) }; + } + + @Post('phone-number') + phoneNumber(@Body() dto: PhoneNumberDto) { + return this.wechat + .getPhoneNumberByCode(dto.code, dto.platform ?? 'mini') + .then((phone) => ({ phone })); + } +} diff --git a/server/dukang-api/src/modules/iam/auth.controller.ts b/server/dukang-api/src/modules/iam/auth.controller.ts index 73128f2..4ff3d71 100644 --- a/server/dukang-api/src/modules/iam/auth.controller.ts +++ b/server/dukang-api/src/modules/iam/auth.controller.ts @@ -3,8 +3,10 @@ import type { Request } from 'express'; import { AuthService } from './auth.service'; import { BindPhoneDto, + BindWechatPhoneDto, BootstrapSessionDto, LoginSmsDto, + LoginWechatDto, RefreshTokenDto, SendSmsDto, } from './dto/auth.dto'; @@ -48,13 +50,26 @@ export class UserAuthController { } @Post('auth/login/wechat') - wechatLogin() { - return this.authService.wechatDisabled(); + @UseGuards(OptionalJwtAuthGuard) + wechatLogin(@Req() req: Request, @Body() dto: LoginWechatDto) { + const guest = (req as Request & { user?: AuthUser }).user; + const guestId = guest?.actorType === 'USER' ? guest.actorId : undefined; + return this.authService.loginUserWechat( + dto.code, + ClientApp.USER_H5, + dto.platform ?? 'h5', + guestId, + ); } @Post('auth/wechat/bind-phone') - bindPhoneLegacy() { - return this.authService.wechatDisabled(); + bindPhoneLegacy(@Body() dto: BindWechatPhoneDto) { + return this.authService.bindWechatPhone( + dto.wxSessionKey, + dto.phone, + dto.code, + ClientApp.USER_H5, + ); } @Get('auth/me') @@ -79,8 +94,8 @@ export class ShopAuthController { } @Post('login/wechat') - wechatLogin() { - return this.authService.wechatDisabled(); + wechatLogin(@Body() dto: LoginWechatDto) { + return this.authService.loginStoreWechat(dto.code, ClientApp.SHOP_H5, dto.platform ?? 'h5'); } } @@ -99,8 +114,8 @@ export class PartnerAuthController { } @Post('login/wechat') - wechatLogin() { - return this.authService.wechatDisabled(); + wechatLogin(@Body() dto: LoginWechatDto) { + return this.authService.loginPartnerWechat(dto.code, ClientApp.PARTNER_H5, dto.platform ?? 'h5'); } } diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index c5a099f..c906dc7 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -12,13 +12,23 @@ import { JwtService } from '@nestjs/jwt'; import { ClientApp, SmsScene } from '@dukang/shared-types'; import { generateUserNo } from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; -import { SMS_PROVIDER } from '../../integrations/integrations.constants'; +import { RedisService } from '../../common/redis/redis.service'; +import { SMS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants'; import { ISmsProvider } from '../../integrations/sms/sms.interface'; +import type { IWechatProvider } from '../../integrations/wechat/wechat.interface'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import type { Prisma } from '@prisma/client'; import type { User } from '@prisma/client'; +type WxSessionPayload = { + openId: string; + unionId?: string; + sessionKey?: string; + clientApp: ClientApp; + guestId?: string; +}; + type UserRow = Pick< User, | 'id' @@ -29,6 +39,7 @@ type UserRow = Pick< | 'nickname' | 'avatarResourceId' | 'wxOpenId' + | 'wxUnionId' | 'mergedIntoUserId' | 'status' > & { @@ -40,7 +51,9 @@ export class AuthService { constructor( private readonly prisma: PrismaService, private readonly jwtService: JwtService, + private readonly redis: RedisService, @Inject(SMS_PROVIDER) private readonly smsProvider: ISmsProvider, + @Inject(WECHAT_PROVIDER) private readonly wechatProvider: IWechatProvider, ) {} async sendSms(phone: string, scene: string) { @@ -286,6 +299,242 @@ export class AuthService { throw new NotImplementedException('FEATURE_DISABLED'); } + assertWechatEnabled() { + if (!this.wechatProvider.isEnabled()) { + this.wechatDisabled(); + } + } + + async loginUserWechat( + code: string, + clientApp: ClientApp, + platform: 'h5' | 'mini' = 'h5', + guestId?: bigint, + ) { + this.assertWechatEnabled(); + const session = + platform === 'mini' + ? await this.wechatProvider.code2Session(code) + : await this.wechatProvider.oauth2AccessToken(code); + + let user = await this.prisma.user.findFirst({ + where: { wxOpenId: session.openId, status: 1, mergedIntoUserId: null }, + include: { avatar: true }, + }); + + if (user?.phone && user.phoneVerifiedAt) { + let activeUser: UserRow = + guestId && guestId !== user.id ? await this.mergeUsers(guestId, user.id) : (user as UserRow); + activeUser = await this.prisma.user.update({ + where: { id: activeUser.id }, + data: { + wxUnionId: session.unionId ?? activeUser.wxUnionId, + }, + include: { avatar: true }, + }); + return this.buildSessionResponse(activeUser, clientApp, activeUser.deviceKey); + } + + const wxSessionKey = randomUUID(); + await this.redis.setJson( + `wx:session:${wxSessionKey}`, + { + openId: session.openId, + unionId: session.unionId, + sessionKey: 'sessionKey' in session ? session.sessionKey : undefined, + clientApp, + guestId: guestId?.toString(), + } satisfies WxSessionPayload, + 1800, + ); + + if (user && !user.phoneVerifiedAt) { + return { + needBindPhone: true, + wxSessionKey, + actorType: 'USER', + actorId: user.id.toString(), + phoneVerified: false, + user: this.formatUserProfile(user), + }; + } + + return { + needBindPhone: true, + wxSessionKey, + phoneVerified: false, + }; + } + + async bindWechatPhone( + wxSessionKey: string, + phone: string, + code: string, + clientApp: ClientApp, + ) { + this.assertWechatEnabled(); + await this.smsProvider.verify(phone, code, SmsScene.BIND_PHONE); + + const wxSession = await this.redis.getJson(`wx:session:${wxSessionKey}`); + if (!wxSession) throw new BadRequestException('微信会话已过期,请重新授权'); + + const guestId = wxSession.guestId ? BigInt(wxSession.guestId) : undefined; + const wxUser = await this.prisma.user.findFirst({ + where: { wxOpenId: wxSession.openId, status: 1, mergedIntoUserId: null }, + include: { avatar: true }, + }); + + const existingPhone = await this.prisma.user.findUnique({ + where: { phone }, + include: { avatar: true }, + }); + + let targetUserId: bigint | null = wxUser?.id ?? null; + + if (!wxUser && !existingPhone) { + if (guestId) { + try { + const guest = await this.assertActiveUser(guestId); + const updated = await this.prisma.user.update({ + where: { id: guest.id }, + data: { + phone, + phoneVerifiedAt: new Date(), + wxOpenId: wxSession.openId, + wxUnionId: wxSession.unionId, + nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname, + }, + }); + targetUserId = updated.id; + } catch { + /* fall through */ + } + } + if (!targetUserId) { + const created = await this.prisma.user.create({ + data: { + phone, + phoneVerifiedAt: new Date(), + wxOpenId: wxSession.openId, + wxUnionId: wxSession.unionId, + userNo: generateUserNo(), + nickname: `用户${phone.slice(-4)}`, + cityPreference: { + create: { + selectedCityCode: '410100', + selectedDistrict: '郑州市', + }, + }, + }, + }); + targetUserId = created.id; + } + } else if (existingPhone) { + await this.assertActiveUser(existingPhone.id); + const updated = await this.prisma.user.update({ + where: { id: existingPhone.id }, + data: { + wxOpenId: wxSession.openId, + wxUnionId: wxSession.unionId, + phoneVerifiedAt: existingPhone.phoneVerifiedAt ?? new Date(), + }, + }); + targetUserId = updated.id; + if (guestId && guestId !== updated.id) { + targetUserId = (await this.mergeUsers(guestId, updated.id)).id; + } + } else if (wxUser) { + if (wxUser.phone && wxUser.phone !== phone) { + throw new BadRequestException('手机号已被其他账号占用'); + } + const updated = await this.prisma.user.update({ + where: { id: wxUser.id }, + data: { + phone, + phoneVerifiedAt: new Date(), + wxUnionId: wxSession.unionId ?? wxUser.wxUnionId, + }, + }); + targetUserId = updated.id; + } + + if (!targetUserId) throw new BadRequestException('绑定失败'); + const user = await this.assertActiveUser(targetUserId); + await this.redis.del(`wx:session:${wxSessionKey}`); + return this.buildSessionResponse(user, clientApp, user.deviceKey); + } + + async loginStoreWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') { + this.assertWechatEnabled(); + const session = + platform === 'mini' + ? await this.wechatProvider.code2Session(code) + : await this.wechatProvider.oauth2AccessToken(code); + + let account = await this.prisma.storeAccount.findFirst({ + where: { wxOpenId: session.openId }, + include: { store: true }, + }); + + if (!account) { + throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信'); + } + + account = await this.prisma.storeAccount.update({ + where: { id: account.id }, + data: { + wxOpenId: session.openId, + wxUnionId: session.unionId ?? account.wxUnionId, + lastLoginAt: new Date(), + }, + include: { store: true }, + }); + + return this.issueToken('STORE', account.id, clientApp, false, undefined, { + id: account.id.toString(), + storeId: account.storeId.toString(), + name: account.name, + phone: account.phone, + storeName: account.store.name, + }); + } + + async loginPartnerWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') { + this.assertWechatEnabled(); + const session = + platform === 'mini' + ? await this.wechatProvider.code2Session(code) + : await this.wechatProvider.oauth2AccessToken(code); + + let account = await this.prisma.partnerAccount.findFirst({ + where: { wxOpenId: session.openId }, + include: { partner: true }, + }); + + if (!account) { + throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信'); + } + + account = await this.prisma.partnerAccount.update({ + where: { id: account.id }, + data: { + wxOpenId: session.openId, + wxUnionId: session.unionId ?? account.wxUnionId, + lastLoginAt: new Date(), + }, + include: { partner: true }, + }); + + return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, { + id: account.id.toString(), + partnerId: account.partnerId.toString(), + name: account.name, + phone: account.phone, + isPrimary: account.isPrimary === 1, + companyName: account.partner.companyName, + }); + } + private async mergeUsers(guestId: bigint, primaryId: bigint): Promise { if (guestId === primaryId) { return this.assertActiveUser(primaryId); diff --git a/server/dukang-api/src/modules/iam/dto/auth.dto.ts b/server/dukang-api/src/modules/iam/dto/auth.dto.ts index bb17ab6..e61e9e7 100644 --- a/server/dukang-api/src/modules/iam/dto/auth.dto.ts +++ b/server/dukang-api/src/modules/iam/dto/auth.dto.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class SendSmsDto { @IsString() @@ -41,3 +41,28 @@ export class BindPhoneDto { @IsNotEmpty() code: string; } + +export class LoginWechatDto { + @IsString() + @IsNotEmpty() + code: string; + + @IsString() + @IsIn(['h5', 'mini']) + @IsOptional() + platform?: 'h5' | 'mini'; +} + +export class BindWechatPhoneDto { + @IsString() + @IsNotEmpty() + wxSessionKey: string; + + @IsString() + @IsNotEmpty() + phone: string; + + @IsString() + @IsNotEmpty() + code: string; +} diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 7e9e618..ad12c35 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -167,7 +167,19 @@ export class TradeService { throw new BadRequestException('订单状态不可支付'); } - const { externalNo } = await this.payProvider.payOrder(orderId); + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + const openId = user?.wxOpenId ?? undefined; + const payResult = await this.payProvider.payOrder(orderId, openId); + + if (payResult.mode === 'jsapi') { + return { + mode: 'jsapi' as const, + orderId: order.id.toString(), + prepay: payResult.prepay, + }; + } + + const { externalNo } = payResult; const now = new Date(); await this.prisma.$transaction(async (tx) => {