微信授权逻辑修改

This commit is contained in:
2026-07-06 20:56:25 +08:00
parent b12ee13232
commit 9d221cfcd2
12 changed files with 475 additions and 28 deletions
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { WechatLoginResult } from '@dukang/shared-types';
import { SmsScene } from '@dukang/shared-types'; import { SmsScene } from '@dukang/shared-types';
import { bindPhone, type SessionPayload } from '../lib/api'; import { bindPhone, request, type SessionPayload } from '../lib/api';
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone'; import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
import { useSmsCode } from '../lib/use-sms-code'; import { useSmsCode } from '../lib/use-sms-code';
import { useUserSession } from '../contexts/UserSessionContext'; import { useUserSession } from '../contexts/UserSessionContext';
@@ -9,6 +10,10 @@ type PhoneVerifySheetProps = {
open: boolean; open: boolean;
/** 打开时预填手机号(如收货地址中的手机号) */ /** 打开时预填手机号(如收货地址中的手机号) */
defaultPhone?: string; defaultPhone?: string;
mode?: 'bind_phone' | 'wechat_bind_phone';
wxSessionKey?: string;
title?: string;
description?: string;
onClose: () => void; onClose: () => void;
onSuccess: () => void; onSuccess: () => void;
}; };
@@ -16,6 +21,10 @@ type PhoneVerifySheetProps = {
export default function PhoneVerifySheet({ export default function PhoneVerifySheet({
open, open,
defaultPhone, defaultPhone,
mode = 'bind_phone',
wxSessionKey,
title,
description,
onClose, onClose,
onSuccess, onSuccess,
}: PhoneVerifySheetProps) { }: PhoneVerifySheetProps) {
@@ -59,8 +68,28 @@ export default function PhoneVerifySheet({
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
if (mode === 'wechat_bind_phone') {
if (!wxSessionKey) {
setError('微信会话已过期,请重新授权');
return;
}
const data = await request<WechatLoginResult>('USER_H5', '/auth/wechat/bind-phone', {
method: 'POST',
body: JSON.stringify({ wxSessionKey, phone, code }),
});
if (data.accessToken) {
applySession({
accessToken: data.accessToken,
refreshToken: data.refreshToken ?? '',
deviceKey: data.deviceKey,
phoneVerified: !!data.phoneVerified,
user: data.user as SessionPayload['user'],
});
}
} else {
const session = await bindPhone(phone, code); const session = await bindPhone(phone, code);
applySession(session as SessionPayload); applySession(session as SessionPayload);
}
onSuccess(); onSuccess();
onClose(); onClose();
} catch (e) { } catch (e) {
@@ -72,12 +101,19 @@ export default function PhoneVerifySheet({
if (!open) return null; if (!open) return null;
const sheetTitle = title ?? (mode === 'wechat_bind_phone' ? '绑定手机号' : '验证手机号');
const sheetDesc =
description ??
(mode === 'wechat_bind_phone'
? '微信授权成功,请绑定手机号以完成支付'
: '下单前需验证手机号,以便接收订单通知');
return ( return (
<div className="phone-verify-overlay" role="dialog" aria-modal="true"> <div className="phone-verify-overlay" role="dialog" aria-modal="true">
<button type="button" className="phone-verify-backdrop" aria-label="关闭" onClick={onClose} /> <button type="button" className="phone-verify-backdrop" aria-label="关闭" onClick={onClose} />
<div className="phone-verify-sheet"> <div className="phone-verify-sheet">
<h3 className="phone-verify-title"></h3> <h3 className="phone-verify-title">{sheetTitle}</h3>
<p className="phone-verify-desc">便</p> <p className="phone-verify-desc">{sheetDesc}</p>
<div className="login-field"> <div className="login-field">
<span className="login-field-prefix">+86</span> <span className="login-field-prefix">+86</span>
<input <input
@@ -121,7 +157,7 @@ export default function PhoneVerifySheet({
<p className={`login-msg${sentHint && !error ? ' login-msg--hint' : ''}`}>{error || sentHint}</p> <p className={`login-msg${sentHint && !error ? ' login-msg--hint' : ''}`}>{error || sentHint}</p>
)} )}
<button type="button" className="login-sms-btn" disabled={loading} onClick={submit}> <button type="button" className="login-sms-btn" disabled={loading} onClick={submit}>
{loading ? '验证中...' : '确认验证'} {loading ? '验证中...' : mode === 'wechat_bind_phone' ? '确认绑定' : '确认验证'}
</button> </button>
</div> </div>
</div> </div>
+46
View File
@@ -0,0 +1,46 @@
import type { WechatLoginResult } from '@dukang/shared-types';
import { isWechatEnv, weixinSdk } from './weixin';
import {
authorizeWechatForPay,
fetchClientConfig,
fetchUserProfile,
needsWechatAuthForPay,
saveWechatLoginResult,
} from './pay-wechat';
export type WechatAuthEnsureResult =
| { ok: true }
| { ok: false; redirecting: true }
| { ok: false; needBindPhone: true; wxSessionKey: string };
export async function checkNeedsWechatAuth(): Promise<boolean> {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
return needsWechatAuthForPay(config, profile);
}
/** 真实微信支付前确保已绑定微信;OAuth 跳转时返回 redirecting */
export async function ensureWechatAuthForPay(): Promise<WechatAuthEnsureResult> {
if (!isWechatEnv()) return { ok: true };
if (!(await checkNeedsWechatAuth())) return { ok: true };
const result = await authorizeWechatForPay();
if (!result) return { ok: false, redirecting: true };
if (result.needBindPhone && result.wxSessionKey) {
return { ok: false, needBindPhone: true, wxSessionKey: result.wxSessionKey };
}
if (saveWechatLoginResult(result)) {
return { ok: true };
}
return { ok: false, redirecting: true };
}
export async function handleWechatAuthCallback(): Promise<WechatLoginResult | null> {
if (!isWechatEnv()) return null;
return weixinSdk.handleOAuthCallback();
}
export function applyWechatLoginResult(result: WechatLoginResult): boolean {
return saveWechatLoginResult(result);
}
+16 -11
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import TabMainHeader from '../components/TabMainHeader'; import TabMainHeader from '../components/TabMainHeader';
import AppImage from '@dukang/shared-ui/AppImage'; import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api'; import { request, type UserProfile } from '../lib/api';
import { useUserSession } from '../contexts/UserSessionContext'; import { useUserSession } from '../contexts/UserSessionContext';
import ContactCustomerSheet from '../components/ContactCustomerSheet'; import ContactCustomerSheet from '../components/ContactCustomerSheet';
@@ -29,10 +29,8 @@ function formatMoney(amount: number) {
export default function MinePage() { export default function MinePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { profile: sessionProfile, resetSession } = useUserSession(); const { profile: sessionProfile, refreshProfile, resetSession } = useUserSession();
const [profile, setProfile] = useState<Record<string, unknown> | null>( const [profile, setProfile] = useState<UserProfile | null>(sessionProfile);
sessionProfile as Record<string, unknown> | null,
);
const [benefitBalance, setBenefitBalance] = useState(0); const [benefitBalance, setBenefitBalance] = useState(0);
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({}); const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
const [toast, setToast] = useState(''); const [toast, setToast] = useState('');
@@ -40,7 +38,7 @@ export default function MinePage() {
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
request<Record<string, unknown>>('USER_H5', '/auth/me'), request<UserProfile>('USER_H5', '/auth/me'),
request<Array<Record<string, unknown>>>('USER_H5', '/benefit/coupons'), request<Array<Record<string, unknown>>>('USER_H5', '/benefit/coupons'),
...ORDER_SHORTCUTS.map((s) => ...ORDER_SHORTCUTS.map((s) =>
request<{ total: number }>('USER_H5', `/trade/orders?tab=${s.tab}&pageSize=1`), request<{ total: number }>('USER_H5', `/trade/orders?tab=${s.tab}&pageSize=1`),
@@ -48,6 +46,7 @@ export default function MinePage() {
]) ])
.then(([me, coupons, ...totals]) => { .then(([me, coupons, ...totals]) => {
setProfile(me); setProfile(me);
void refreshProfile();
const balance = coupons.reduce((sum, c) => { const balance = coupons.reduce((sum, c) => {
if (String(c.status) === 'ACTIVE') return sum + Number(c.balance || 0); if (String(c.status) === 'ACTIVE') return sum + Number(c.balance || 0);
return sum; return sum;
@@ -60,7 +59,7 @@ export default function MinePage() {
setOrderCounts(counts); setOrderCounts(counts);
}) })
.catch(() => {}); .catch(() => {});
}, []); }, [refreshProfile]);
function showToast(msg: string) { function showToast(msg: string) {
setToast(msg); setToast(msg);
@@ -77,9 +76,10 @@ export default function MinePage() {
void resetSession().then(() => navigate('/')); void resetSession().then(() => navigate('/'));
} }
const nickname = String(profile?.nickname || '用户'); const nickname = profile?.nickname || '用户';
const userNo = String(profile?.userNo || ''); const userNo = profile?.userNo || '';
const avatar = String(profile?.avatarUrl || DEFAULT_AVATAR); const avatar = profile?.avatarUrl || DEFAULT_AVATAR;
const hasWechat = !!profile?.hasWechat;
return ( return (
<div className="mine-page"> <div className="mine-page">
@@ -89,12 +89,17 @@ export default function MinePage() {
<div className="mine-profile"> <div className="mine-profile">
<div className="mine-avatar-wrap"> <div className="mine-avatar-wrap">
<AppImage src={avatar} alt="" wrapperClassName="mine-avatar app-image--fill" /> <AppImage src={avatar} alt="" wrapperClassName="mine-avatar app-image--fill" />
{hasWechat && (
<span className="mine-wechat-badge" title="已绑定微信">
<span className="material-symbols-outlined">chat</span>
</span>
)}
</div> </div>
<div className="mine-profile-info"> <div className="mine-profile-info">
<h1 className="mine-profile-name">{nickname}</h1> <h1 className="mine-profile-name">{nickname}</h1>
<div className="mine-profile-meta"> <div className="mine-profile-meta">
{userNo && <span className="mine-profile-id">ID: {userNo}</span>} {userNo && <span className="mine-profile-id">ID: {userNo}</span>}
<span className="mine-member-tag"></span> <span className="mine-member-tag">{hasWechat ? '微信会员' : '好客会员'}</span>
</div> </div>
</div> </div>
<button <button
@@ -9,6 +9,12 @@ import { track } from '../lib/analytics';
import PhoneVerifySheet from '../components/PhoneVerifySheet'; import PhoneVerifySheet from '../components/PhoneVerifySheet';
import { useUserSession } from '../contexts/UserSessionContext'; import { useUserSession } from '../contexts/UserSessionContext';
import { tryGetClientGpsLocation } from '../lib/client-location'; import { tryGetClientGpsLocation } from '../lib/client-location';
import {
applyWechatLoginResult,
ensureWechatAuthForPay,
handleWechatAuthCallback,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
type Address = { type Address = {
id: string; id: string;
@@ -55,6 +61,8 @@ export default function OrderConfirmPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { phoneVerified, refreshProfile } = useUserSession(); const { phoneVerified, refreshProfile } = useUserSession();
const [showPhoneVerify, setShowPhoneVerify] = useState(false); const [showPhoneVerify, setShowPhoneVerify] = useState(false);
const [showWechatBindPhone, setShowWechatBindPhone] = useState(false);
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
const [pendingSubmit, setPendingSubmit] = useState(false); const [pendingSubmit, setPendingSubmit] = useState(false);
const productId = params.get('productId') || ''; const productId = params.get('productId') || '';
const forceCross = params.get('cross') === '1'; const forceCross = params.get('cross') === '1';
@@ -84,6 +92,17 @@ export default function OrderConfirmPage() {
} }
}, [productId, quantity]); }, [productId, quantity]);
useEffect(() => {
if (!isWechatEnv()) return;
handleWechatAuthCallback()
.then((result) => {
if (result && applyWechatLoginResult(result)) {
void refreshProfile();
}
})
.catch(() => {});
}, [refreshProfile]);
useEffect(() => { useEffect(() => {
if (!productId || !addressId) return; if (!productId || !addressId) return;
request<OrderPreview>('USER_H5', '/trade/orders/preview', { request<OrderPreview>('USER_H5', '/trade/orders/preview', {
@@ -158,6 +177,33 @@ export default function OrderConfirmPage() {
setShowPhoneVerify(true); setShowPhoneVerify(true);
return; return;
} }
const authResult = await ensureWechatAuthForPay();
if (!authResult.ok) {
if ('needBindPhone' in authResult) {
setWxSessionKey(authResult.wxSessionKey);
setShowWechatBindPhone(true);
setPendingSubmit(true);
return;
}
return;
}
setLoading(true);
setMsg('');
try {
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
}
async function handleWechatPhoneBound() {
await refreshProfile();
if (!pendingSubmit) return;
setPendingSubmit(false);
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
try { try {
@@ -347,6 +393,19 @@ export default function OrderConfirmPage() {
}} }}
onSuccess={handlePhoneVerified} onSuccess={handlePhoneVerified}
/> />
<PhoneVerifySheet
open={showWechatBindPhone}
mode="wechat_bind_phone"
wxSessionKey={wxSessionKey ?? undefined}
defaultPhone={selectedAddress?.phone}
onClose={() => {
setShowWechatBindPhone(false);
setWxSessionKey(null);
setPendingSubmit(false);
}}
onSuccess={handleWechatPhoneBound}
/>
</div> </div>
); );
} }
+27 -11
View File
@@ -1,19 +1,20 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import type { WechatLoginResult, WechatPayOrderResult } from '@dukang/shared-types'; import type { WechatPayOrderResult } from '@dukang/shared-types';
import SubPageHeader from '../components/SubPageHeader'; import SubPageHeader from '../components/SubPageHeader';
import PhoneVerifySheet from '../components/PhoneVerifySheet';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { buildOrderConfirmUrl } from '../lib/navigation'; import { buildOrderConfirmUrl } from '../lib/navigation';
import { import {
authorizeWechatForPay, authorizeWechatForPay,
buildLoginReturnUrl,
fetchClientConfig, fetchClientConfig,
fetchUserProfile, fetchUserProfile,
isWechatAuthRequiredError, isWechatAuthRequiredError,
needsWechatAuthForPay, needsWechatAuthForPay,
saveWechatLoginResult, saveWechatLoginResult,
} from '../lib/pay-wechat'; } from '../lib/pay-wechat';
import { isWechatEnv, weixinSdk } from '../lib/weixin'; import { applyWechatLoginResult, handleWechatAuthCallback } from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
function sleep(ms: number) { function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
@@ -30,7 +31,6 @@ async function waitOrderPaid(orderId: string, maxAttempts = 15) {
export default function PayPage() { export default function PayPage() {
const [params] = useSearchParams(); const [params] = useSearchParams();
const location = useLocation();
const orderId = params.get('orderId') || ''; const orderId = params.get('orderId') || '';
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -38,6 +38,8 @@ export default function PayPage() {
const [mockMode, setMockMode] = useState(true); const [mockMode, setMockMode] = useState(true);
const [needsWechatAuth, setNeedsWechatAuth] = useState(false); const [needsWechatAuth, setNeedsWechatAuth] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [showBindPhone, setShowBindPhone] = useState(false);
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
const refreshPayReadiness = useCallback(async () => { const refreshPayReadiness = useCallback(async () => {
try { try {
@@ -51,10 +53,11 @@ export default function PayPage() {
}, []); }, []);
const handleWechatLoginResult = useCallback( const handleWechatLoginResult = useCallback(
async (result: WechatLoginResult) => { async (result: Parameters<typeof applyWechatLoginResult>[0]) => {
if (result.needBindPhone && result.wxSessionKey) { if (result.needBindPhone && result.wxSessionKey) {
setMsg('微信授权成功,请先绑定手机号'); setWxSessionKey(result.wxSessionKey);
navigate(buildLoginReturnUrl(location.pathname, location.search)); setShowBindPhone(true);
setMsg('');
return; return;
} }
if (saveWechatLoginResult(result)) { if (saveWechatLoginResult(result)) {
@@ -62,7 +65,7 @@ export default function PayPage() {
await refreshPayReadiness(); await refreshPayReadiness();
} }
}, },
[location.pathname, location.search, navigate, refreshPayReadiness], [refreshPayReadiness],
); );
useEffect(() => { useEffect(() => {
@@ -71,8 +74,7 @@ export default function PayPage() {
useEffect(() => { useEffect(() => {
if (!isWechatEnv()) return; if (!isWechatEnv()) return;
weixinSdk handleWechatAuthCallback()
.handleOAuthCallback()
.then((result) => { .then((result) => {
if (result) void handleWechatLoginResult(result); if (result) void handleWechatLoginResult(result);
}) })
@@ -121,6 +123,7 @@ export default function PayPage() {
if (result.mode === 'jsapi' && result.prepay) { if (result.mode === 'jsapi' && result.prepay) {
setMockMode(false); setMockMode(false);
const { weixinSdk } = await import('../lib/weixin');
await weixinSdk.pay(result.prepay); await weixinSdk.pay(result.prepay);
const paid = await waitOrderPaid(orderId); const paid = await waitOrderPaid(orderId);
if (!paid) { if (!paid) {
@@ -190,6 +193,19 @@ export default function PayPage() {
{loading ? '支付中...' : needsWechatAuth ? '请先授权微信' : '确认支付'} {loading ? '支付中...' : needsWechatAuth ? '请先授权微信' : '确认支付'}
</button> </button>
</div> </div>
<PhoneVerifySheet
open={showBindPhone}
mode="wechat_bind_phone"
wxSessionKey={wxSessionKey ?? undefined}
onClose={() => {
setShowBindPhone(false);
setWxSessionKey(null);
}}
onSuccess={() => {
void refreshPayReadiness();
}}
/>
</div> </div>
); );
} }
+20
View File
@@ -4307,6 +4307,26 @@
flex-shrink: 0; flex-shrink: 0;
} }
.mine-wechat-badge {
position: absolute;
right: -2px;
bottom: -2px;
width: 24px;
height: 24px;
border-radius: 50%;
background: #07c160;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.mine-wechat-badge .material-symbols-outlined {
font-size: 14px;
}
.mine-avatar { .mine-avatar {
width: 80px; width: 80px;
height: 80px; height: 80px;
@@ -149,6 +149,51 @@ export class WechatApiProvider implements IWechatProvider {
}; };
} }
async fetchOAuthUserInfo(
accessToken: string,
openId: string,
actorRef?: WechatActorRef,
): Promise<import('./wechat.interface').WechatOAuthUserInfo> {
const maskedUrl = new URL('https://api.weixin.qq.com/sns/userinfo');
maskedUrl.searchParams.set('access_token', '***');
maskedUrl.searchParams.set('openid', openId);
maskedUrl.searchParams.set('lang', 'zh_CN');
const apiUrl = new URL('https://api.weixin.qq.com/sns/userinfo');
apiUrl.searchParams.set('access_token', accessToken);
apiUrl.searchParams.set('openid', openId);
apiUrl.searchParams.set('lang', 'zh_CN');
const data = await this.fetchJson<{
openid?: string;
nickname?: string;
headimgurl?: string;
unionid?: string;
errcode?: number;
errmsg?: string;
}>(apiUrl.toString());
const ok = !!data.openid;
await logWechatAuth(this.prisma, {
scene: 'USERINFO',
requestUrl: maskedUrl.toString(),
requestBody: { lang: 'zh_CN' },
responseBody: ok
? { openid: data.openid, nickname: data.nickname, unionid: data.unionid }
: { errcode: data.errcode, errmsg: data.errmsg },
externalNo: data.openid ?? openId,
status: ok ? 'SUCCESS' : 'FAILED',
errorMessage: ok ? undefined : data.errmsg || '微信用户信息获取失败',
actorRef,
});
if (!data.openid) {
throw new InternalServerErrorException(data.errmsg || '微信用户信息获取失败');
}
return {
openId: data.openid,
nickname: data.nickname,
headImgUrl: data.headimgurl,
unionId: data.unionid,
};
}
async createJssdkConfig(url: string, actorRef?: WechatActorRef) { async createJssdkConfig(url: string, actorRef?: WechatActorRef) {
try { try {
const ticket = await this.getJsapiTicket(); const ticket = await this.getJsapiTicket();
@@ -27,6 +27,10 @@ export class WechatDisabledProvider implements IWechatProvider {
return this.disabled(); return this.disabled();
} }
fetchOAuthUserInfo() {
return this.disabled();
}
createJssdkConfig() { createJssdkConfig() {
return this.disabled(); return this.disabled();
} }
@@ -14,6 +14,13 @@ export type WechatOAuthSession = {
refreshToken?: string; refreshToken?: string;
}; };
export type WechatOAuthUserInfo = {
openId: string;
nickname?: string;
headImgUrl?: string;
unionId?: string;
};
export type WechatPayNotifyResult = { export type WechatPayNotifyResult = {
transactionId: string; transactionId: string;
outTradeNo: string; outTradeNo: string;
@@ -36,6 +43,13 @@ export interface IWechatProvider {
/** 公众号 H5 OAuth code 换 openId */ /** 公众号 H5 OAuth code 换 openId */
oauth2AccessToken(code: string, actorRef?: { refType: string; refId: bigint }): Promise<WechatOAuthSession>; oauth2AccessToken(code: string, actorRef?: { refType: string; refId: bigint }): Promise<WechatOAuthSession>;
/** 公众号 OAuth access_token 拉取用户昵称头像(snsapi_userinfo */
fetchOAuthUserInfo(
accessToken: string,
openId: string,
actorRef?: { refType: string; refId: bigint },
): Promise<WechatOAuthUserInfo>;
/** JSSDK 签名配置 */ /** JSSDK 签名配置 */
createJssdkConfig(url: string, actorRef?: { refType: string; refId: bigint }): Promise<WechatJssdkConfig>; createJssdkConfig(url: string, actorRef?: { refType: string; refId: bigint }): Promise<WechatJssdkConfig>;
@@ -3,6 +3,7 @@ import type { Request } from 'express';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { import {
BindPhoneDto, BindPhoneDto,
BindWechatDto,
BindWechatPhoneDto, BindWechatPhoneDto,
BootstrapSessionDto, BootstrapSessionDto,
LoginSmsDto, LoginSmsDto,
@@ -78,6 +79,17 @@ export class UserAuthController {
); );
} }
@Post('auth/wechat/bind')
@UseGuards(JwtAuthGuard)
bindWechat(@CurrentUser() user: AuthUser, @Body() dto: BindWechatDto) {
return this.authService.bindUserWechat(
user.actorId,
{ code: dto.code, wxSessionKey: dto.wxSessionKey },
ClientApp.USER_H5,
dto.platform ?? 'h5',
);
}
@Get('auth/me') @Get('auth/me')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
me(@CurrentUser() user: AuthUser) { me(@CurrentUser() user: AuthUser) {
@@ -28,6 +28,7 @@ type WxSessionPayload = {
openId: string; openId: string;
unionId?: string; unionId?: string;
sessionKey?: string; sessionKey?: string;
accessToken?: string;
clientApp: ClientApp; clientApp: ClientApp;
guestId?: string; guestId?: string;
}; };
@@ -512,6 +513,10 @@ export class AuthService {
}, },
include: { avatar: true }, include: { avatar: true },
}); });
if (session.accessToken) {
const synced = await this.syncWechatUserProfile(activeUser.id, session.accessToken, session.openId);
if (synced) activeUser = synced as UserRow;
}
this.analyticsService.trackOneSafe(activeUser.id, clientApp, { this.analyticsService.trackOneSafe(activeUser.id, clientApp, {
eventName: 'wechat_login', eventName: 'wechat_login',
extraJson: { platform }, extraJson: { platform },
@@ -523,6 +528,18 @@ export class AuthService {
return this.buildSessionResponse(activeUser, clientApp, activeUser.deviceKey); return this.buildSessionResponse(activeUser, clientApp, activeUser.deviceKey);
} }
if (guestId) {
try {
const guest = await this.assertActiveUser(guestId);
if (guest.phoneVerifiedAt && !guest.wxOpenId) {
const activeUser = await this.attachWechatToUser(guest.id, session, clientApp, platform);
return this.buildSessionResponse(activeUser, clientApp, activeUser.deviceKey);
}
} catch (e) {
if (e instanceof BadRequestException) throw e;
}
}
const wxSessionKey = randomUUID(); const wxSessionKey = randomUUID();
await this.redis.setJson( await this.redis.setJson(
`wx:session:${wxSessionKey}`, `wx:session:${wxSessionKey}`,
@@ -530,6 +547,7 @@ export class AuthService {
openId: session.openId, openId: session.openId,
unionId: session.unionId, unionId: session.unionId,
sessionKey: 'sessionKey' in session ? session.sessionKey : undefined, sessionKey: 'sessionKey' in session ? session.sessionKey : undefined,
accessToken: session.accessToken,
clientApp, clientApp,
guestId: guestId?.toString(), guestId: guestId?.toString(),
} satisfies WxSessionPayload, } satisfies WxSessionPayload,
@@ -647,6 +665,9 @@ export class AuthService {
} }
if (!targetUserId) throw new BadRequestException('绑定失败'); if (!targetUserId) throw new BadRequestException('绑定失败');
if (wxSession.accessToken) {
await this.syncWechatUserProfile(targetUserId, wxSession.accessToken, wxSession.openId);
}
const user = await this.assertActiveUser(targetUserId); const user = await this.assertActiveUser(targetUserId);
await this.redis.del(`wx:session:${wxSessionKey}`); await this.redis.del(`wx:session:${wxSessionKey}`);
this.trackSmsUserEvent(user.id, clientApp, 'bind_phone', { this.trackSmsUserEvent(user.id, clientApp, 'bind_phone', {
@@ -664,6 +685,53 @@ export class AuthService {
return this.buildSessionResponse(user, clientApp, user.deviceKey); return this.buildSessionResponse(user, clientApp, user.deviceKey);
} }
async bindUserWechat(
userId: bigint,
input: { code?: string; wxSessionKey?: string },
clientApp: ClientApp,
platform: 'h5' | 'mini' = 'h5',
) {
this.assertWechatEnabled();
if (!input.code && !input.wxSessionKey) {
throw new BadRequestException('请提供微信授权 code 或会话');
}
let openId: string;
let unionId: string | undefined;
let accessToken: string | undefined;
const actorRef = { refType: 'USER', refId: userId };
if (input.code) {
const session =
platform === 'mini'
? await this.wechatProvider.code2Session(input.code, actorRef)
: await this.wechatProvider.oauth2AccessToken(input.code, actorRef);
openId = session.openId;
unionId = session.unionId;
accessToken = session.accessToken;
} else {
const wxSession = await this.redis.getJson<WxSessionPayload>(`wx:session:${input.wxSessionKey}`);
if (!wxSession) throw new BadRequestException('微信会话已过期,请重新授权');
openId = wxSession.openId;
unionId = wxSession.unionId;
accessToken = wxSession.accessToken;
await this.redis.del(`wx:session:${input.wxSessionKey}`);
}
const user = await this.attachWechatToUser(
userId,
{ openId, unionId, accessToken },
clientApp,
platform,
{ skipLoginEvents: true },
);
this.analyticsService.trackOneSafe(userId, clientApp, {
eventName: 'wechat_bind',
extraJson: { platform },
});
return this.buildSessionResponse(user, clientApp, user.deviceKey);
}
async loginStoreWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') { async loginStoreWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') {
this.assertWechatEnabled(); this.assertWechatEnabled();
const session = const session =
@@ -871,6 +939,113 @@ export class AuthService {
return this.issueToken('USER', user.id, clientApp, phoneVerified, this.formatUserProfile(user), undefined, undefined, deviceKey); return this.issueToken('USER', user.id, clientApp, phoneVerified, this.formatUserProfile(user), undefined, undefined, deviceKey);
} }
private isDefaultNickname(nickname: string | null | undefined) {
if (!nickname || nickname === '访客') return true;
return /^用户\d{4}$/.test(nickname);
}
private async syncWechatUserProfile(
userId: bigint,
accessToken: string,
openId: string,
): Promise<UserRow | null> {
try {
const info = await this.wechatProvider.fetchOAuthUserInfo(accessToken, openId, {
refType: 'USER',
refId: userId,
});
const current = await this.prisma.user.findUnique({
where: { id: userId },
include: { avatar: true },
});
if (!current) return null;
const data: {
nickname?: string;
avatarResourceId?: bigint;
} = {};
if (info.nickname && this.isDefaultNickname(current.nickname)) {
data.nickname = info.nickname;
}
if (info.headImgUrl && !current.avatarResourceId) {
const avatar = await this.prisma.commonResource.create({
data: {
ownerType: 'USER',
ownerId: userId,
bizType: 'AVATAR',
mediaType: 'IMAGE',
ossBucket: 'wechat',
ossKey: `wx-avatar/${openId}`,
url: info.headImgUrl,
status: 'ACTIVE',
},
});
data.avatarResourceId = avatar.id;
}
if (!data.nickname && !data.avatarResourceId) {
return current as UserRow;
}
return this.prisma.user.update({
where: { id: userId },
data,
include: { avatar: true },
});
} catch {
return null;
}
}
private async attachWechatToUser(
userId: bigint,
session: { openId: string; unionId?: string; accessToken?: string },
clientApp: ClientApp,
platform: string,
options?: { skipLoginEvents?: boolean },
): Promise<UserRow> {
const conflict = await this.prisma.user.findFirst({
where: {
wxOpenId: session.openId,
id: { not: userId },
status: 1,
mergedIntoUserId: null,
},
});
if (conflict) {
throw new BadRequestException('该微信已绑定其他账号');
}
let user = (await this.prisma.user.update({
where: { id: userId },
data: {
wxOpenId: session.openId,
wxUnionId: session.unionId ?? undefined,
},
include: { avatar: true },
})) as UserRow;
if (session.accessToken) {
const synced = await this.syncWechatUserProfile(userId, session.accessToken, session.openId);
if (synced) user = synced as UserRow;
}
if (!options?.skipLoginEvents) {
this.analyticsService.trackOneSafe(userId, clientApp, {
eventName: 'wechat_login',
extraJson: { platform },
});
this.analyticsService.trackOneSafe(userId, clientApp, {
eventName: 'login_success',
extraJson: { method: 'wechat', platform },
});
}
return user;
}
private formatUserProfile(user: UserRow) { private formatUserProfile(user: UserRow) {
return { return {
id: user.id.toString(), id: user.id.toString(),
@@ -68,3 +68,18 @@ export class BindWechatPhoneDto {
@IsNotEmpty() @IsNotEmpty()
code: string; code: string;
} }
export class BindWechatDto {
@IsString()
@IsOptional()
code?: string;
@IsString()
@IsOptional()
wxSessionKey?: string;
@IsString()
@IsIn(['h5', 'mini'])
@IsOptional()
platform?: 'h5' | 'mini';
}