微信SDK接通
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dukang/shared-ui": "workspace:*",
|
"@dukang/shared-ui": "workspace:*",
|
||||||
"@dukang/shared-types": "workspace:*",
|
"@dukang/shared-types": "workspace:*",
|
||||||
|
"@dukang/weixin-sdk": "workspace:*",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.0"
|
"react-router-dom": "^6.26.0"
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
|
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||||
|
|
||||||
function formatMoney(n: number) {
|
function formatMoney(n: number) {
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
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 openTime = String(store?.openTime || '10:00');
|
||||||
const closeTime = String(store?.closeTime || '22: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 (
|
return (
|
||||||
<div className="shop-home-page">
|
<div className="shop-home-page">
|
||||||
<header className="shop-home-header">
|
<header className="shop-home-header">
|
||||||
@@ -55,7 +72,7 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="shop-home-scan">
|
<section className="shop-home-scan">
|
||||||
<button type="button" className="shop-home-scan-btn" onClick={() => navigate('/redeem')}>
|
<button type="button" className="shop-home-scan-btn" onClick={handleScan}>
|
||||||
<span className="material-symbols-outlined">qr_code_scanner</span>
|
<span className="material-symbols-outlined">qr_code_scanner</span>
|
||||||
</button>
|
</button>
|
||||||
<p className="shop-home-scan-label">扫码核销</p>
|
<p className="shop-home-scan-label">扫码核销</p>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
function formatAmount(n: number) {
|
function formatAmount(n: number) {
|
||||||
@@ -8,6 +8,7 @@ function formatAmount(n: number) {
|
|||||||
|
|
||||||
export default function RedeemConfirmPage() {
|
export default function RedeemConfirmPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [token, setToken] = useState('');
|
const [token, setToken] = useState('');
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -20,6 +21,11 @@ export default function RedeemConfirmPage() {
|
|||||||
.catch(() => setStoreName('当前门店'));
|
.catch(() => setStoreName('当前门店'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scanned = searchParams.get('token');
|
||||||
|
if (scanned) setToken(scanned);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
async function confirm() {
|
async function confirm() {
|
||||||
if (!token.trim()) {
|
if (!token.trim()) {
|
||||||
setMsg('请在开发者选项中输入核销码');
|
setMsg('请在开发者选项中输入核销码');
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dukang/shared-types": "workspace:*",
|
"@dukang/shared-types": "workspace:*",
|
||||||
"@dukang/shared-ui": "workspace:*",
|
"@dukang/shared-ui": "workspace:*",
|
||||||
|
"@dukang/weixin-sdk": "workspace:*",
|
||||||
"element-china-area-data": "^6.1.0",
|
"element-china-area-data": "^6.1.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { getWechatLocation } from '@dukang/weixin-sdk';
|
||||||
|
import { weixinSdk } from './weixin';
|
||||||
|
|
||||||
export type ClientGpsLocation = {
|
export type ClientGpsLocation = {
|
||||||
province?: string;
|
province?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
@@ -7,55 +10,15 @@ export type ClientGpsLocation = {
|
|||||||
address?: string;
|
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 不阻塞下单 */
|
/** 尝试获取客户端 GPS(微信优先,其次 H5 Geolocation),失败返回 null 不阻塞下单 */
|
||||||
export async function tryGetClientGpsLocation(): Promise<ClientGpsLocation | null> {
|
export async function tryGetClientGpsLocation(): Promise<ClientGpsLocation | null> {
|
||||||
if (typeof window !== 'undefined' && window.wx?.getLocation) {
|
const loc = await weixinSdk.getLocation();
|
||||||
const wxResult = await new Promise<WxLocationResult | null>((resolve) => {
|
if (!loc) return null;
|
||||||
window.wx!.getLocation!({
|
|
||||||
type: 'gcj02',
|
|
||||||
success: (res) => resolve(res),
|
|
||||||
fail: () => resolve(null),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (wxResult) {
|
|
||||||
return {
|
return {
|
||||||
latitude: wxResult.latitude,
|
latitude: loc.latitude,
|
||||||
longitude: wxResult.longitude,
|
longitude: loc.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 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated 使用 tryGetClientGpsLocation */
|
||||||
|
export { getWechatLocation };
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } 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 { request, saveSession } from '../lib/api';
|
import { request, saveSession } from '../lib/api';
|
||||||
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||||
|
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -12,6 +14,38 @@ export default function LoginPage() {
|
|||||||
const [agreed, setAgreed] = useState(true);
|
const [agreed, setAgreed] = useState(true);
|
||||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
|
const [wxSessionKey, setWxSessionKey] = useState<string | null>(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() {
|
function ensureAgreed() {
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
@@ -31,7 +65,7 @@ export default function LoginPage() {
|
|||||||
setMsg('');
|
setMsg('');
|
||||||
await request('USER_H5', '/auth/sms/send', {
|
await request('USER_H5', '/auth/sms/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone, scene: 'USER_LOGIN' }),
|
body: JSON.stringify({ phone, scene: bindMode ? 'BIND_PHONE' : 'USER_LOGIN' }),
|
||||||
});
|
});
|
||||||
setMsg('验证码已发送(Mock: 123456)');
|
setMsg('验证码已发送(Mock: 123456)');
|
||||||
setCodeCooldown(60);
|
setCodeCooldown(60);
|
||||||
@@ -60,6 +94,14 @@ export default function LoginPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
|
if (bindMode && wxSessionKey) {
|
||||||
|
const data = await request<WechatLoginResult>('USER_H5', '/auth/wechat/bind-phone', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ wxSessionKey, phone, code }),
|
||||||
|
});
|
||||||
|
handleWechatLoginResult(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const data = await request<{
|
const data = await request<{
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
@@ -77,9 +119,19 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function wechatLogin() {
|
async function wechatLogin() {
|
||||||
if (!ensureAgreed()) return;
|
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 (
|
return (
|
||||||
@@ -97,7 +149,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<main className="login-main">
|
<main className="login-main">
|
||||||
<div className="login-card">
|
<div className="login-card">
|
||||||
<h3 className="login-card-title">手机验证码登录</h3>
|
<h3 className="login-card-title">{bindMode ? '绑定手机号' : '手机验证码登录'}</h3>
|
||||||
<div className="login-field">
|
<div className="login-field">
|
||||||
<span className="login-field-prefix">+86</span>
|
<span className="login-field-prefix">+86</span>
|
||||||
<input
|
<input
|
||||||
@@ -138,10 +190,12 @@ export default function LoginPage() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={login}
|
onClick={login}
|
||||||
>
|
>
|
||||||
{loading ? '登录中...' : '登录'}
|
{loading ? '登录中...' : bindMode ? '绑定并登录' : '登录'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!bindMode && (
|
||||||
|
<>
|
||||||
<div className="login-divider">
|
<div className="login-divider">
|
||||||
<span className="login-divider-line" />
|
<span className="login-divider-line" />
|
||||||
<span className="login-divider-text">或者</span>
|
<span className="login-divider-text">或者</span>
|
||||||
@@ -152,6 +206,8 @@ export default function LoginPage() {
|
|||||||
<span className="material-symbols-outlined login-wechat-icon">chat</span>
|
<span className="material-symbols-outlined login-wechat-icon">chat</span>
|
||||||
<span>微信一键授权</span>
|
<span>微信一键授权</span>
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="login-footer">
|
<footer className="login-footer">
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import type { 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 { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||||
|
|
||||||
export default function PayPage() {
|
export default function PayPage() {
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
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 [mockMode, setMockMode] = useState(true);
|
||||||
|
|
||||||
function goBackConfirm() {
|
function goBackConfirm() {
|
||||||
navigate(
|
navigate(
|
||||||
@@ -20,10 +23,22 @@ export default function PayPage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pay() {
|
async function pay() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await request('USER_H5', `/trade/orders/${orderId}/pay`, { method: 'POST' });
|
const result = await request<WechatPayOrderResult>('USER_H5', `/trade/orders/${orderId}/pay`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.mode === 'jsapi' && result.prepay) {
|
||||||
|
setMockMode(false);
|
||||||
|
await weixinSdk.init();
|
||||||
|
await weixinSdk.pay(result.prepay);
|
||||||
|
navigate('/orders?tab=pending_ship');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
navigate('/orders?tab=pending_ship');
|
navigate('/orders?tab=pending_ship');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : '支付失败');
|
alert(e instanceof Error ? e.message : '支付失败');
|
||||||
@@ -39,8 +54,14 @@ export default function PayPage() {
|
|||||||
<div className="pay-icon">
|
<div className="pay-icon">
|
||||||
<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">Mock 微信支付</p>
|
<p className="headline-lg text-primary">
|
||||||
<p className="text-muted body-md" style={{ marginTop: 8 }}>preV1 环境模拟支付,点击确认即完成</p>
|
{mockMode ? (isWechatEnv() ? '微信支付' : 'Mock 微信支付') : '微信支付'}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted body-md" style={{ marginTop: 8 }}>
|
||||||
|
{mockMode
|
||||||
|
? 'preV1 Mock 模式:点击确认即完成;开启真实支付后将调起微信收银台'
|
||||||
|
: '请在微信内完成支付'}
|
||||||
|
</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">
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ export interface AppConfig {
|
|||||||
mockPay: boolean;
|
mockPay: boolean;
|
||||||
mockDeliveryAuto: boolean;
|
mockDeliveryAuto: boolean;
|
||||||
autoApproveStore: boolean;
|
autoApproveStore: boolean;
|
||||||
|
wechatAuthEnabled: boolean;
|
||||||
|
wechatPayEnabled: boolean;
|
||||||
|
wxAppId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadAppConfig(env?: Record<string, string | undefined>): AppConfig {
|
export function loadAppConfig(env?: Record<string, string | undefined>): AppConfig {
|
||||||
@@ -17,5 +20,8 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
|
|||||||
mockPay: e.MOCK_PAY !== 'false',
|
mockPay: e.MOCK_PAY !== 'false',
|
||||||
mockDeliveryAuto: e.MOCK_DELIVERY_AUTO !== 'false',
|
mockDeliveryAuto: e.MOCK_DELIVERY_AUTO !== 'false',
|
||||||
autoApproveStore: e.AUTO_APPROVE_STORE !== '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 ?? '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './enums';
|
export * from './enums';
|
||||||
export * from './api';
|
export * from './api';
|
||||||
export * from './config';
|
export * from './config';
|
||||||
|
export * from './wechat';
|
||||||
|
|||||||
@@ -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<string, unknown> }
|
||||||
|
| { 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<string, unknown>;
|
||||||
|
store?: Record<string, unknown>;
|
||||||
|
partner?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WechatLoginPlatform = 'h5' | 'mini';
|
||||||
|
|
||||||
|
export interface WechatGpsLocation {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
speed?: number;
|
||||||
|
accuracy?: number;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T>(
|
||||||
|
config: WeixinSdkConfig,
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Client-App': config.clientApp,
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<WechatLoginResult> {
|
||||||
|
const resolvedPlatform = platform ?? (getRuntimePlatform() === 'mini' ? 'mini' : 'h5');
|
||||||
|
return apiRequest<WechatLoginResult>(config, '/auth/login/wechat', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ code, platform: resolvedPlatform }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理 OAuth 回调 URL 中的 code 参数并登录 */
|
||||||
|
export async function handleWechatOAuthCallback(
|
||||||
|
config: WeixinSdkConfig,
|
||||||
|
searchParams?: URLSearchParams,
|
||||||
|
): Promise<WechatLoginResult | null> {
|
||||||
|
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<WechatLoginResult> {
|
||||||
|
return apiRequest<WechatLoginResult>(config, '/auth/wechat/bind-phone', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取微信手机号(小程序 getPhoneNumber 返回的 code,需后端解密) */
|
||||||
|
export async function getWechatPhoneNumber(config: WeixinSdkConfig): Promise<string> {
|
||||||
|
const platform = getRuntimePlatform();
|
||||||
|
if (platform !== 'mini') {
|
||||||
|
throw new Error('H5 环境请使用短信验证码绑定手机号');
|
||||||
|
}
|
||||||
|
const phoneCode = await new Promise<string>((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<WechatLoginResult | void> {
|
||||||
|
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('请在微信内打开以使用微信登录');
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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<typeof invokeWechatPay>[0]) =>
|
||||||
|
invokeWechatPay(prepay, { apiBase: config.apiBase, clientApp: config.clientApp }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<void> | null = null;
|
||||||
|
let configured = false;
|
||||||
|
|
||||||
|
function loadScript(): Promise<void> {
|
||||||
|
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<HTMLScriptElement>('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<string, string> = { '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<void> {
|
||||||
|
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<void>((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<void> {
|
||||||
|
if (!isJssdkReady()) {
|
||||||
|
await initWechatJssdk(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WechatGpsLocation | null> {
|
||||||
|
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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { WechatJsapiPrepayParams } from '@dukang/shared-types';
|
||||||
|
import { getRuntimePlatform } from './env';
|
||||||
|
import { ensureJssdkReady } from './jssdk';
|
||||||
|
|
||||||
|
function invokeBridgePay(params: WechatJsapiPrepayParams): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void>((resolve) => {
|
||||||
|
if (window.WeixinJSBridge) resolve();
|
||||||
|
else document.addEventListener('WeixinJSBridgeReady', () => resolve(), { once: true });
|
||||||
|
});
|
||||||
|
return invokeBridgePay(prepay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('当前环境不支持微信支付');
|
||||||
|
}
|
||||||
@@ -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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<T> = {
|
||||||
|
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<string, boolean>) => 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<string, unknown>,
|
||||||
|
cb: (res: { err_msg?: string }) => void,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_JS_API_LIST = [
|
||||||
|
'getLocation',
|
||||||
|
'scanQRCode',
|
||||||
|
'chooseWXPay',
|
||||||
|
'updateAppMessageShareData',
|
||||||
|
'updateTimelineShareData',
|
||||||
|
] as const;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"lib": ["ES2020", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Generated
+16
@@ -90,6 +90,9 @@ importers:
|
|||||||
'@dukang/shared-ui':
|
'@dukang/shared-ui':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared-ui
|
version: link:../../packages/shared-ui
|
||||||
|
'@dukang/weixin-sdk':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/weixin-sdk
|
||||||
react:
|
react:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
@@ -124,6 +127,9 @@ importers:
|
|||||||
'@dukang/shared-ui':
|
'@dukang/shared-ui':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared-ui
|
version: link:../../packages/shared-ui
|
||||||
|
'@dukang/weixin-sdk':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/weixin-sdk
|
||||||
element-china-area-data:
|
element-china-area-data:
|
||||||
specifier: ^6.1.0
|
specifier: ^6.1.0
|
||||||
version: 6.1.0
|
version: 6.1.0
|
||||||
@@ -180,6 +186,16 @@ importers:
|
|||||||
specifier: ^5.4.5
|
specifier: ^5.4.5
|
||||||
version: 5.9.3
|
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:
|
server/dukang-api:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@dukang/domain':
|
'@dukang/domain':
|
||||||
|
|||||||
@@ -10,4 +10,15 @@ MOCK_DELIVERY_AUTO=true
|
|||||||
AUTO_APPROVE_STORE=true
|
AUTO_APPROVE_STORE=true
|
||||||
|
|
||||||
# 反向代理后提取真实客户端 IP(下单 IP 定位)
|
# 反向代理后提取真实客户端 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
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export const SMS_PROVIDER = 'SMS_PROVIDER';
|
export const SMS_PROVIDER = 'SMS_PROVIDER';
|
||||||
export const PAY_PROVIDER = 'PAY_PROVIDER';
|
export const PAY_PROVIDER = 'PAY_PROVIDER';
|
||||||
export const DELIVERY_PROVIDER = 'DELIVERY_PROVIDER';
|
export const DELIVERY_PROVIDER = 'DELIVERY_PROVIDER';
|
||||||
|
export const WECHAT_PROVIDER = 'WECHAT_PROVIDER';
|
||||||
|
|||||||
@@ -1,21 +1,50 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
|
import { loadAppConfig } from '@dukang/shared-types';
|
||||||
import { SmsMockProvider } from './sms/sms.mock.provider';
|
import { SmsMockProvider } from './sms/sms.mock.provider';
|
||||||
import { PayMockProvider } from './pay/pay.mock.provider';
|
import { PayMockProvider } from './pay/pay.mock.provider';
|
||||||
|
import { PayWechatProvider } from './pay/pay.wechat.provider';
|
||||||
import { DeliveryMockProvider } from './delivery/delivery.mock.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 { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
||||||
|
import type { IWechatProvider } from './wechat/wechat.interface';
|
||||||
|
import type { IPayProvider } from './pay/pay.interface';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE })],
|
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE })],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: SMS_PROVIDER, useClass: SmsMockProvider },
|
{ 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 },
|
{ provide: DELIVERY_PROVIDER, useClass: DeliveryMockProvider },
|
||||||
SmsMockProvider,
|
SmsMockProvider,
|
||||||
PayMockProvider,
|
|
||||||
DeliveryMockProvider,
|
DeliveryMockProvider,
|
||||||
],
|
],
|
||||||
exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER],
|
exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER],
|
||||||
})
|
})
|
||||||
export class IntegrationsModule {}
|
export class IntegrationsModule {}
|
||||||
|
|||||||
@@ -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 {
|
export interface IPayProvider {
|
||||||
payOrder(orderId: bigint): Promise<{ externalNo: string }>;
|
payOrder(orderId: bigint, openId?: string): Promise<PayOrderResult>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { loadAppConfig } from '@dukang/shared-types';
|
import { loadAppConfig } from '@dukang/shared-types';
|
||||||
import { IPayProvider } from './pay.interface';
|
import type { IPayProvider, PayOrderResult } from './pay.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PayMockProvider implements IPayProvider {
|
export class PayMockProvider implements IPayProvider {
|
||||||
private readonly config = loadAppConfig();
|
private readonly config = loadAppConfig();
|
||||||
|
|
||||||
async payOrder(_orderId: bigint): Promise<{ externalNo: string }> {
|
async payOrder(_orderId: bigint, _openId?: string): Promise<PayOrderResult> {
|
||||||
if (!this.config.mockPay) {
|
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()}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<PayOrderResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WechatCodeSession> {
|
||||||
|
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<WechatOAuthSession> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
const cached = await this.redis.getJson<TokenCache>(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<string> {
|
||||||
|
const cached = await this.redis.getJson<TicketCache>(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<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WechatCodeSession>;
|
||||||
|
|
||||||
|
/** 公众号 H5 OAuth code 换 openId */
|
||||||
|
oauth2AccessToken(code: string): Promise<WechatOAuthSession>;
|
||||||
|
|
||||||
|
/** JSSDK 签名配置 */
|
||||||
|
createJssdkConfig(url: string): Promise<WechatJssdkConfig>;
|
||||||
|
|
||||||
|
/** 构建公众号 OAuth 授权 URL */
|
||||||
|
buildOAuthUrl(redirectUri: string, state: string, scope?: string): string;
|
||||||
|
|
||||||
|
/** 小程序手机号 code 解密(或调用微信 getPhoneNumber 接口) */
|
||||||
|
getPhoneNumberByCode(code: string, platform: 'mini' | 'h5'): Promise<string>;
|
||||||
|
|
||||||
|
/** 创建 JSAPI 预支付参数 */
|
||||||
|
createJsapiPrepay(params: {
|
||||||
|
orderNo: string;
|
||||||
|
description: string;
|
||||||
|
amountFen: number;
|
||||||
|
openId: string;
|
||||||
|
notifyUrl: string;
|
||||||
|
}): Promise<WechatJsapiPrepayParams>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { IamModule } from '../iam/iam.module';
|
import { IamModule } from '../iam/iam.module';
|
||||||
|
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||||
import { ResourceService } from './resource.service';
|
import { ResourceService } from './resource.service';
|
||||||
import { EventService } from './event.service';
|
import { EventService } from './event.service';
|
||||||
import { TicketService } from './ticket.service';
|
import { TicketService } from './ticket.service';
|
||||||
@@ -8,10 +9,11 @@ import { ResourceController } from './resource.controller';
|
|||||||
import { EventController } from './event.controller';
|
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';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IamModule],
|
imports: [IamModule, IntegrationsModule],
|
||||||
controllers: [ResourceController, EventController, TicketController, ThirdPartyLogController],
|
controllers: [ResourceController, EventController, TicketController, ThirdPartyLogController, WechatController],
|
||||||
providers: [ResourceService, EventService, TicketService, ThirdPartyLogService],
|
providers: [ResourceService, EventService, TicketService, ThirdPartyLogService],
|
||||||
exports: [ResourceService, EventService, TicketService],
|
exports: [ResourceService, EventService, TicketService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@ import type { Request } from 'express';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import {
|
import {
|
||||||
BindPhoneDto,
|
BindPhoneDto,
|
||||||
|
BindWechatPhoneDto,
|
||||||
BootstrapSessionDto,
|
BootstrapSessionDto,
|
||||||
LoginSmsDto,
|
LoginSmsDto,
|
||||||
|
LoginWechatDto,
|
||||||
RefreshTokenDto,
|
RefreshTokenDto,
|
||||||
SendSmsDto,
|
SendSmsDto,
|
||||||
} from './dto/auth.dto';
|
} from './dto/auth.dto';
|
||||||
@@ -48,13 +50,26 @@ export class UserAuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('auth/login/wechat')
|
@Post('auth/login/wechat')
|
||||||
wechatLogin() {
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
return this.authService.wechatDisabled();
|
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')
|
@Post('auth/wechat/bind-phone')
|
||||||
bindPhoneLegacy() {
|
bindPhoneLegacy(@Body() dto: BindWechatPhoneDto) {
|
||||||
return this.authService.wechatDisabled();
|
return this.authService.bindWechatPhone(
|
||||||
|
dto.wxSessionKey,
|
||||||
|
dto.phone,
|
||||||
|
dto.code,
|
||||||
|
ClientApp.USER_H5,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('auth/me')
|
@Get('auth/me')
|
||||||
@@ -79,8 +94,8 @@ export class ShopAuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('login/wechat')
|
@Post('login/wechat')
|
||||||
wechatLogin() {
|
wechatLogin(@Body() dto: LoginWechatDto) {
|
||||||
return this.authService.wechatDisabled();
|
return this.authService.loginStoreWechat(dto.code, ClientApp.SHOP_H5, dto.platform ?? 'h5');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,8 +114,8 @@ export class PartnerAuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('login/wechat')
|
@Post('login/wechat')
|
||||||
wechatLogin() {
|
wechatLogin(@Body() dto: LoginWechatDto) {
|
||||||
return this.authService.wechatDisabled();
|
return this.authService.loginPartnerWechat(dto.code, ClientApp.PARTNER_H5, dto.platform ?? 'h5');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,23 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import { ClientApp, SmsScene } from '@dukang/shared-types';
|
import { ClientApp, SmsScene } from '@dukang/shared-types';
|
||||||
import { generateUserNo } from '@dukang/domain';
|
import { generateUserNo } from '@dukang/domain';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
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 { ISmsProvider } from '../../integrations/sms/sms.interface';
|
||||||
|
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import type { User } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
|
|
||||||
|
type WxSessionPayload = {
|
||||||
|
openId: string;
|
||||||
|
unionId?: string;
|
||||||
|
sessionKey?: string;
|
||||||
|
clientApp: ClientApp;
|
||||||
|
guestId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type UserRow = Pick<
|
type UserRow = Pick<
|
||||||
User,
|
User,
|
||||||
| 'id'
|
| 'id'
|
||||||
@@ -29,6 +39,7 @@ type UserRow = Pick<
|
|||||||
| 'nickname'
|
| 'nickname'
|
||||||
| 'avatarResourceId'
|
| 'avatarResourceId'
|
||||||
| 'wxOpenId'
|
| 'wxOpenId'
|
||||||
|
| 'wxUnionId'
|
||||||
| 'mergedIntoUserId'
|
| 'mergedIntoUserId'
|
||||||
| 'status'
|
| 'status'
|
||||||
> & {
|
> & {
|
||||||
@@ -40,7 +51,9 @@ export class AuthService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
@Inject(SMS_PROVIDER) private readonly smsProvider: ISmsProvider,
|
@Inject(SMS_PROVIDER) private readonly smsProvider: ISmsProvider,
|
||||||
|
@Inject(WECHAT_PROVIDER) private readonly wechatProvider: IWechatProvider,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async sendSms(phone: string, scene: string) {
|
async sendSms(phone: string, scene: string) {
|
||||||
@@ -286,6 +299,242 @@ export class AuthService {
|
|||||||
throw new NotImplementedException('FEATURE_DISABLED');
|
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<WxSessionPayload>(`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<UserRow> {
|
private async mergeUsers(guestId: bigint, primaryId: bigint): Promise<UserRow> {
|
||||||
if (guestId === primaryId) {
|
if (guestId === primaryId) {
|
||||||
return this.assertActiveUser(primaryId);
|
return this.assertActiveUser(primaryId);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class SendSmsDto {
|
export class SendSmsDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -41,3 +41,28 @@ export class BindPhoneDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
code: string;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -167,7 +167,19 @@ export class TradeService {
|
|||||||
throw new BadRequestException('订单状态不可支付');
|
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();
|
const now = new Date();
|
||||||
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user