diff --git a/apps/mini-user/src/components/WechatShareBootstrap.tsx b/apps/mini-user/src/components/WechatShareBootstrap.tsx index 4a2070a..fc611da 100644 --- a/apps/mini-user/src/components/WechatShareBootstrap.tsx +++ b/apps/mini-user/src/components/WechatShareBootstrap.tsx @@ -1,7 +1,7 @@ import Taro from '@tarojs/taro'; import { captureIosJssdkEntryUrl } from '@dukang/weixin-sdk'; import { useEffect, useRef } from 'react'; -import { finishLoginNavigate, goLogin } from '../lib/auth-nav'; +import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav'; import { toast } from '../lib/api'; import { saveWechatLoginResult } from '../lib/pay-wechat'; import { applyWechatShare } from '../lib/wechat-share'; @@ -70,11 +70,16 @@ export default function WechatShareBootstrap() { if (!result) return; if (saveWechatLoginResult(result)) { toast('微信授权成功', 'success'); + const ret = returnFromLogin || params.get('return') || undefined; + if (result.accountMerged) { + forceReloadAfterAccountMerge(ret); + return; + } if ( returnFromLogin !== undefined || currentPagePathWithQuery().includes('/pages/login/') ) { - finishLoginNavigate(returnFromLogin || params.get('return') || undefined); + finishLoginNavigate(ret); } return; } diff --git a/apps/mini-user/src/lib/api.ts b/apps/mini-user/src/lib/api.ts index 1ecfe6c..f3e626e 100644 --- a/apps/mini-user/src/lib/api.ts +++ b/apps/mini-user/src/lib/api.ts @@ -1,6 +1,6 @@ import Taro from '@tarojs/taro'; import { ClientApp } from '@dukang/shared-types'; -import { goLogin } from './auth-nav'; +import { goLogin, forceReloadAfterAccountMerge } from './auth-nav'; function resolveApiBase(): string { const origin = @@ -111,7 +111,13 @@ export async function request(path: string, options: ReqOptions = { const stillCurrent = !!token && getToken() === token; if (stillCurrent) { clearAuth(); - if (!isOnLoginPage()) redirectToLogin(); + const mergedMsg = body?.message || ''; + if (/账号已合并/.test(mergedMsg)) { + // 合并后旧 JWT 失效:强制刷新,不引导「重新登录」 + forceReloadAfterAccountMerge(); + } else if (!isOnLoginPage()) { + redirectToLogin(); + } } throw new Error(body?.message || '登录已过期,请重新登录'); } @@ -131,6 +137,8 @@ export function toast(title: string, icon: 'success' | 'error' | 'none' = 'none' export type SessionPayload = { accessToken: string; refreshToken?: string; + phoneVerified?: boolean; + accountMerged?: boolean; }; export type UserProfile = { diff --git a/apps/mini-user/src/lib/auth-nav.ts b/apps/mini-user/src/lib/auth-nav.ts index c3e9173..dc2ed6c 100644 --- a/apps/mini-user/src/lib/auth-nav.ts +++ b/apps/mini-user/src/lib/auth-nav.ts @@ -70,3 +70,46 @@ export function finishLoginNavigate(returnTo?: string) { Taro.reLaunch({ url: '/pages/home/index' }); } + +/** + * 账号合并后强制整页刷新,避免旧会话栈 / 旧用户缓存继续提示绑定手机号。 + * H5:按当前路径推算出落地 URL 后 location.replace;小程序:reLaunch。 + */ +export function forceReloadAfterAccountMerge(returnTo?: string) { + const raw = (returnTo || '').trim(); + let target = ''; + try { + target = raw ? decodeURIComponent(raw) : ''; + } catch { + target = raw; + } + const pathOnly = target.split('?')[0]; + const safePath = + pathOnly && pathOnly.startsWith('/pages/') && !pathOnly.includes('/pages/login') + ? target + : '/pages/home/index'; + const launchPath = safePath.split('?')[0]; + + if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') { + const { origin, pathname, search } = window.location; + const marker = '/pages/'; + const idx = pathname.indexOf(marker); + let href: string; + if (idx >= 0) { + href = `${origin}${pathname.slice(0, idx)}${safePath}`; + } else if (window.location.hash.includes('/pages/')) { + href = `${origin}${pathname}${search}#${safePath}`; + } else { + const base = pathname.replace(/\/$/, '') || ''; + href = `${origin}${base}${safePath.startsWith('/') ? safePath : `/${safePath}`}`; + } + window.location.replace(href); + return; + } + + if (TAB_PAGES.has(launchPath)) { + Taro.reLaunch({ url: launchPath }); + return; + } + Taro.reLaunch({ url: safePath.startsWith('/') ? safePath : `/${safePath}` }); +} diff --git a/apps/mini-user/src/pages/login/index.tsx b/apps/mini-user/src/pages/login/index.tsx index 3a868e1..c4dd81d 100644 --- a/apps/mini-user/src/pages/login/index.tsx +++ b/apps/mini-user/src/pages/login/index.tsx @@ -10,7 +10,7 @@ import { import PageShell from '../../components/PageShell'; import WechatLoginButton from '../../components/WechatLoginButton'; import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types'; -import { finishLoginNavigate } from '../../lib/auth-nav'; +import { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav'; import { bindWechatForUser, loginWithWechat, @@ -126,6 +126,10 @@ export default function LoginPage() { .catch(() => {}); } toast(successToast, 'success'); + if (data.accountMerged) { + forceReloadAfterAccountMerge(returnTo); + return; + } finishLoginNavigate(returnTo); } diff --git a/apps/mini-user/src/pages/order-confirm/index.tsx b/apps/mini-user/src/pages/order-confirm/index.tsx index 851a501..5213ddb 100644 --- a/apps/mini-user/src/pages/order-confirm/index.tsx +++ b/apps/mini-user/src/pages/order-confirm/index.tsx @@ -168,7 +168,10 @@ export default function OrderConfirmPage() { if (!phonePromptSkipped.current) { try { const profile = await fetchUserProfile(); - if (!profile.phoneVerified) { + const phoneBound = + !!profile.phoneVerified || + (!!profile.phone && /^1[3-9]\d{9}$/.test(String(profile.phone))); + if (!phoneBound) { const { confirm, cancel } = await Taro.showModal({ title: '建议绑定手机号', content: '绑定后便于订单通知与售后联系;也可跳过,不绑定也能继续下单。', diff --git a/packages/shared-types/src/wechat.ts b/packages/shared-types/src/wechat.ts index 04901d6..1acfdb2 100644 --- a/packages/shared-types/src/wechat.ts +++ b/packages/shared-types/src/wechat.ts @@ -45,6 +45,8 @@ export interface WechatLoginResult { actorType?: string; actorId?: string; phoneVerified?: boolean; + /** 本次登录/绑定触发了账号合并,客户端应强制刷新页面 */ + accountMerged?: boolean; needBindPhone?: boolean; wxSessionKey?: string; user?: Record; diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index 1b8d381..fd67017 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -784,6 +784,15 @@ export class AuthService { } if (guestId && guestId !== user.id) { user = await this.mergeUsers(guestId, user.id); + this.analyticsService.trackOneSafe(user.id, clientApp, { + eventName: 'sms_login', + extraJson: { method: 'sms', accountMerged: true }, + }); + this.analyticsService.trackOneSafe(user.id, clientApp, { + eventName: 'login_success', + extraJson: { method: 'sms', accountMerged: true }, + }); + return this.buildSessionResponse(user, clientApp, user.deviceKey, { accountMerged: true }); } else { await this.assertActiveUser(user.id); } @@ -817,6 +826,7 @@ export class AuthService { const existing = await this.prisma.user.findUnique({ where: { phone: normalizedPhone } }); let targetUser: UserRow; + let accountMerged = false; if (!existing) { targetUser = await this.prisma.user.update({ @@ -834,6 +844,14 @@ export class AuthService { targetUser = existing; } else { targetUser = await this.mergeUsers(guest.id, existing.id); + accountMerged = true; + if (!targetUser.phoneVerifiedAt) { + targetUser = await this.prisma.user.update({ + where: { id: targetUser.id }, + data: { phoneVerifiedAt: new Date() }, + include: { avatar: true }, + }); + } } } @@ -841,7 +859,9 @@ export class AuthService { phone: this.maskPhone(normalizedPhone), }); - return this.buildSessionResponse(targetUser, clientApp, targetUser.deviceKey); + return this.buildSessionResponse(targetUser, clientApp, targetUser.deviceKey, { + accountMerged, + }); } async loginStore(phone: string, code: string, clientApp: ClientApp) { @@ -1061,8 +1081,14 @@ export class AuthService { // 微信登录不再强制绑定手机号;phoneVerified=false 也可签发会话,下单页仅提示可选绑定 if (user) { - let activeUser: UserRow = - guestId && guestId !== user.id ? await this.mergeUsers(guestId, user.id) : (user as UserRow); + let accountMerged = false; + let activeUser: UserRow; + if (guestId && guestId !== user.id) { + activeUser = await this.mergeUsers(guestId, user.id); + accountMerged = true; + } else { + activeUser = user as UserRow; + } activeUser = await this.prisma.user.update({ where: { id: activeUser.id }, data: { @@ -1076,13 +1102,54 @@ export class AuthService { } this.analyticsService.trackOneSafe(activeUser.id, clientApp, { eventName: 'wechat_login', - extraJson: { platform }, + extraJson: { platform, accountMerged }, }); this.analyticsService.trackOneSafe(activeUser.id, clientApp, { eventName: 'login_success', - extraJson: { method: 'wechat', platform }, + extraJson: { method: 'wechat', platform, accountMerged }, }); - return this.buildSessionResponse(activeUser, clientApp, activeUser.deviceKey); + return this.buildSessionResponse(activeUser, clientApp, activeUser.deviceKey, { accountMerged }); + } + + // 历史脏数据:openId 仍在已合并访客上 → 跟随主账号并补挂微信身份 + const legacyMerged = await this.prisma.user.findFirst({ + where: { wxOpenId: session.openId, mergedIntoUserId: { not: null } }, + select: { mergedIntoUserId: true }, + }); + if (legacyMerged?.mergedIntoUserId) { + let primary = await this.assertActiveUser(legacyMerged.mergedIntoUserId); + if (!primary.wxOpenId) { + primary = await this.prisma.user.update({ + where: { id: primary.id }, + data: { + wxOpenId: session.openId, + wxUnionId: session.unionId ?? primary.wxUnionId, + }, + include: { avatar: true }, + }); + } + await this.prisma.user.updateMany({ + where: { wxOpenId: session.openId, id: { not: primary.id } }, + data: { wxOpenId: null, wxUnionId: null }, + }); + let accountMerged = false; + if (guestId && guestId !== primary.id) { + primary = await this.mergeUsers(guestId, primary.id); + accountMerged = true; + } + if (session.accessToken) { + const synced = await this.syncWechatUserProfile(primary.id, session.accessToken, session.openId); + if (synced) primary = synced as UserRow; + } + this.analyticsService.trackOneSafe(primary.id, clientApp, { + eventName: 'wechat_login', + extraJson: { platform, accountMerged, recoveredFromMerge: true }, + }); + this.analyticsService.trackOneSafe(primary.id, clientApp, { + eventName: 'login_success', + extraJson: { method: 'wechat', platform, accountMerged }, + }); + return this.buildSessionResponse(primary, clientApp, primary.deviceKey, { accountMerged }); } if (guestId) { @@ -1201,9 +1268,29 @@ export class AuthService { }, }); targetUserId = updated.id; + let accountMerged = false; if (guestId && guestId !== updated.id) { targetUserId = (await this.mergeUsers(guestId, updated.id)).id; + accountMerged = true; } + if (wxSession.accessToken) { + await this.syncWechatUserProfile(targetUserId, wxSession.accessToken, wxSession.openId); + } + const user = await this.assertActiveUser(targetUserId); + await this.redis.del(`wx:session:${wxSessionKey}`); + this.trackSmsUserEvent(user.id, clientApp, 'bind_phone', { + phone: this.maskPhone(phone), + method: 'wechat', + }); + this.analyticsService.trackOneSafe(user.id, clientApp, { + eventName: 'wechat_phone', + extraJson: { method: 'bind_phone', accountMerged }, + }); + this.analyticsService.trackOneSafe(user.id, clientApp, { + eventName: 'login_success', + extraJson: { method: 'wechat_bind', accountMerged }, + }); + return this.buildSessionResponse(user, clientApp, user.deviceKey, { accountMerged }); } else if (wxUser) { if (wxUser.phone && wxUser.phone !== phone) { throw new BadRequestException('手机号已被其他账号占用'); @@ -1531,6 +1618,30 @@ export class AuthService { throw new BadRequestException('目标账号无效'); } + // 先迁微信身份到主账号,再清空访客,否则下次 OAuth 找不到 openId 又会建无手机号访客 + const guestWxOpenId = guest.wxOpenId; + const guestWxUnionId = guest.wxUnionId; + if (guestWxOpenId || guestWxUnionId) { + await tx.user.update({ + where: { id: guestId }, + data: { wxOpenId: null, wxUnionId: null }, + }); + if (guestWxOpenId && !primary.wxOpenId) { + await tx.user.update({ + where: { id: primaryId }, + data: { + wxOpenId: guestWxOpenId, + wxUnionId: guestWxUnionId ?? primary.wxUnionId, + }, + }); + } else if (guestWxUnionId && !primary.wxUnionId) { + await tx.user.update({ + where: { id: primaryId }, + data: { wxUnionId: guestWxUnionId }, + }); + } + } + await tx.order.updateMany({ where: { userId: guestId }, data: { userId: primaryId } }); await tx.userAddress.updateMany({ where: { userId: guestId }, data: { userId: primaryId } }); await tx.benefitCoupon.updateMany({ where: { userId: guestId }, data: { userId: primaryId } }); @@ -1588,12 +1699,34 @@ export class AuthService { await tx.user.update({ where: { id: primaryId }, data: { deviceKey: deviceKeyToTransfer } }); } + if (!primary.avatarResourceId && guest.avatarResourceId) { + await tx.user.update({ + where: { id: primaryId }, + data: { avatarResourceId: guest.avatarResourceId }, + }); + await tx.user.update({ + where: { id: guestId }, + data: { avatarResourceId: null }, + }); + } + + if ((!primary.nickname || primary.nickname === '访客' || /^用户\d{4}$/.test(primary.nickname)) + && guest.nickname + && guest.nickname !== '访客') { + await tx.user.update({ + where: { id: primaryId }, + data: { nickname: guest.nickname }, + }); + } + await tx.user.update({ where: { id: guestId }, data: { mergedIntoUserId: primaryId, status: 0, deviceKey: null, + wxOpenId: null, + wxUnionId: null, }, }); }); @@ -1617,9 +1750,26 @@ export class AuthService { return user; } - private buildSessionResponse(user: UserRow, clientApp: ClientApp, deviceKey: string | null) { + private buildSessionResponse( + user: UserRow, + clientApp: ClientApp, + deviceKey: string | null, + extras?: { accountMerged?: boolean }, + ) { const phoneVerified = !!user.phoneVerifiedAt; - 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, + ), + ...(extras?.accountMerged ? { accountMerged: true as const } : {}), + }; } private isDefaultNickname(nickname: string | null | undefined) {