import { randomUUID } from 'crypto'; import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException, NotImplementedException, UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ClientApp, SmsScene } from '@dukang/shared-types'; import { generateUserNo } from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; import { RedisService } from '../../common/redis/redis.service'; import { SMS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants'; import { ISmsProvider } from '../../integrations/sms/sms.interface'; import type { SmsActorRef } from '../../integrations/sms/sms.interface'; import { SmsCodeStore } from '../../integrations/sms/sms-code.store'; import type { IWechatProvider } from '../../integrations/wechat/wechat.interface'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { verifyPassword } from '../../common/crypto/password.util'; import { AnalyticsService } from '../analytics/analytics.service'; import { UserAddressService } from './user-address.service'; import type { User } from '@prisma/client'; type WxSessionPayload = { openId: string; unionId?: string; sessionKey?: string; accessToken?: string; clientApp: ClientApp; guestId?: string; }; type UserRow = Pick< User, | 'id' | 'userNo' | 'deviceKey' | 'phone' | 'phoneVerifiedAt' | 'nickname' | 'avatarResourceId' | 'wxOpenId' | 'wxUnionId' | 'mergedIntoUserId' | 'status' > & { avatar?: { url: string } | null; }; @Injectable() export class AuthService { constructor( private readonly prisma: PrismaService, private readonly jwtService: JwtService, private readonly redis: RedisService, @Inject(SMS_PROVIDER) private readonly smsProvider: ISmsProvider, @Inject(WECHAT_PROVIDER) private readonly wechatProvider: IWechatProvider, private readonly analyticsService: AnalyticsService, private readonly smsCodeStore: SmsCodeStore, private readonly userAddressService: UserAddressService, ) {} private assertMobilePhone(phone: string) { const trimmed = phone.trim(); if (!/^1[3-9]\d{9}$/.test(trimmed)) { throw new BadRequestException('请输入正确的手机号码'); } return trimmed; } private maskPhone(phone: string) { return `${phone.slice(0, 3)}****${phone.slice(-4)}`; } private clientAppForScene(scene: string): ClientApp { switch (scene) { case SmsScene.STORE_LOGIN: return ClientApp.SHOP_H5; case SmsScene.PARTNER_LOGIN: case SmsScene.PARTNER_STAFF_ADD: return ClientApp.PARTNER_H5; case SmsScene.HQ_LOGIN: return ClientApp.HQ_WEB; default: return ClientApp.USER_H5; } } private async resolveSmsActorRef( phone: string, scene: string, guestUserId?: bigint, ): Promise { switch (scene) { case SmsScene.USER_LOGIN: case SmsScene.BIND_PHONE: { if (guestUserId) return { refType: 'USER', refId: guestUserId }; const user = await this.prisma.user.findUnique({ where: { phone }, select: { id: true }, }); return user ? { refType: 'USER', refId: user.id } : undefined; } case SmsScene.STORE_LOGIN: { const account = await this.prisma.storeAccount.findUnique({ where: { phone }, select: { id: true }, }); return account ? { refType: 'STORE', refId: account.id } : undefined; } case SmsScene.PARTNER_LOGIN: case SmsScene.PARTNER_STAFF_ADD: { const account = await this.prisma.partnerAccount.findUnique({ where: { phone }, select: { id: true }, }); return account ? { refType: 'PARTNER', refId: account.id } : undefined; } case SmsScene.HQ_LOGIN: { const account = await this.prisma.hqAccount.findUnique({ where: { phone }, select: { id: true }, }); return account ? { refType: 'HQ', refId: account.id } : undefined; } default: return undefined; } } private trackSmsUserEvent( userId: bigint | undefined, clientApp: ClientApp | string, eventName: string, extraJson: Record, thirdPartyLogId?: bigint, ) { if (!userId) return; this.analyticsService.trackOneSafe(userId, clientApp, { eventName, refType: thirdPartyLogId ? 'THIRD_PARTY_LOG' : undefined, refId: thirdPartyLogId, extraJson, }); } private async verifySmsForUser( phone: string, code: string, scene: SmsScene.USER_LOGIN | SmsScene.BIND_PHONE, clientApp: ClientApp, userId?: bigint, ) { try { await this.smsProvider.verify(phone, code, scene); } catch (err) { if (err instanceof BadRequestException) { this.trackSmsUserEvent(userId, clientApp, 'sms_verify_fail', { scene, phone: this.maskPhone(phone), reason: err.message, }); } throw err; } } async sendSms( phone: string, scene: string, opts?: { guestUserId?: bigint; clientApp?: ClientApp }, ) { const normalizedPhone = this.assertMobilePhone(phone); if (!Object.values(SmsScene).includes(scene as SmsScene)) { throw new BadRequestException('无效的验证码场景'); } const clientApp = opts?.clientApp ?? this.clientAppForScene(scene); const actorRef = await this.resolveSmsActorRef(normalizedPhone, scene, opts?.guestUserId); const userId = actorRef?.refType === 'USER' ? actorRef.refId : opts?.guestUserId; await this.smsCodeStore.assertSendCooldown(normalizedPhone); try { const result = await this.smsProvider.send(normalizedPhone, scene, actorRef); await this.smsCodeStore.setSendCooldown(normalizedPhone); this.trackSmsUserEvent( userId, clientApp, 'sms_send', { scene, phone: this.maskPhone(normalizedPhone), status: result.ok ? 'success' : 'failed', ...(result.errorMessage ? { message: result.errorMessage } : {}), }, result.logId, ); if (!result.ok) { throw new BadRequestException(result.errorMessage ?? '短信发送失败'); } } catch (err) { if (err instanceof BadRequestException) throw err; const message = err instanceof Error ? err.message : '短信发送失败'; this.trackSmsUserEvent(userId, clientApp, 'sms_send', { scene, phone: this.maskPhone(normalizedPhone), status: 'failed', message, }); throw new BadRequestException(message); } return { sent: true }; } async bootstrapSession(deviceKey: string | undefined, clientApp: ClientApp) { let user: UserRow | null = null; let resolvedDeviceKey = deviceKey?.trim() || null; if (resolvedDeviceKey) { user = await this.prisma.user.findFirst({ where: { deviceKey: resolvedDeviceKey, status: 1, mergedIntoUserId: null, }, include: { avatar: true }, }); } if (!user) { resolvedDeviceKey = randomUUID(); user = await this.prisma.user.create({ data: { userNo: generateUserNo(), deviceKey: resolvedDeviceKey, nickname: '访客', cityPreference: { create: { selectedCityCode: '410100', selectedDistrict: '郑州市', }, }, }, include: { avatar: true }, }); } return this.buildSessionResponse(user, clientApp, resolvedDeviceKey); } async refreshAccessToken(refreshToken: string, clientApp: ClientApp) { try { const payload = this.jwtService.verify(refreshToken); if (payload.clientApp !== clientApp || payload.actorType !== 'USER') { throw new UnauthorizedException('Invalid refresh token'); } const user = await this.assertActiveUser(BigInt(payload.actorId)); return this.buildSessionResponse(user, clientApp, user.deviceKey); } catch (err) { if (err instanceof UnauthorizedException) throw err; throw new UnauthorizedException('Invalid refresh token'); } } async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) { const normalizedPhone = this.assertMobilePhone(phone); const existingUser = await this.prisma.user.findUnique({ where: { phone: normalizedPhone }, select: { id: true }, }); await this.verifySmsForUser( normalizedPhone, code, SmsScene.USER_LOGIN, clientApp, guestId ?? existingUser?.id, ); let user: UserRow | null = await this.prisma.user.findUnique({ where: { phone: normalizedPhone }, include: { avatar: true }, }); if (!user) { if (guestId) { try { const guest = await this.assertActiveUser(guestId); if (!guest.phone) { user = await this.prisma.user.update({ where: { id: guestId }, data: { phone: normalizedPhone, phoneVerifiedAt: new Date(), nickname: guest.nickname === '访客' ? `用户${normalizedPhone.slice(-4)}` : guest.nickname, }, include: { avatar: true }, }); } } catch { /* guest invalid, fall through to create */ } } if (!user) { user = await this.prisma.user.create({ data: { phone: normalizedPhone, phoneVerifiedAt: new Date(), userNo: generateUserNo(), nickname: `用户${normalizedPhone.slice(-4)}`, cityPreference: { create: { selectedCityCode: '410100', selectedDistrict: '郑州市', }, }, }, include: { avatar: true }, }); } } else { if (!user.phoneVerifiedAt) { user = await this.prisma.user.update({ where: { id: user.id }, data: { phoneVerifiedAt: new Date() }, include: { avatar: true }, }); } if (guestId && guestId !== user.id) { user = await this.mergeUsers(guestId, user.id); } else { await this.assertActiveUser(user.id); } } if (!user) throw new BadRequestException('登录失败'); this.analyticsService.trackOneSafe(user.id, clientApp, { eventName: 'sms_login', extraJson: { method: 'sms' }, }); this.analyticsService.trackOneSafe(user.id, clientApp, { eventName: 'login_success', extraJson: { method: 'sms' }, }); return this.buildSessionResponse(user, clientApp, user.deviceKey); } async bindPhone(actorId: bigint, phone: string, code: string, clientApp: ClientApp) { const normalizedPhone = this.assertMobilePhone(phone); await this.verifySmsForUser(normalizedPhone, code, SmsScene.BIND_PHONE, clientApp, actorId); const guest = await this.assertActiveUser(actorId); if (guest.phone && guest.phoneVerifiedAt) { if (guest.phone === normalizedPhone) { return this.buildSessionResponse(guest, clientApp, guest.deviceKey); } throw new BadRequestException('当前账号已绑定其他手机号'); } const existing = await this.prisma.user.findUnique({ where: { phone: normalizedPhone } }); let targetUser: UserRow; if (!existing) { targetUser = await this.prisma.user.update({ where: { id: guest.id }, data: { phone: normalizedPhone, phoneVerifiedAt: new Date(), nickname: guest.nickname === '访客' ? `用户${normalizedPhone.slice(-4)}` : guest.nickname, }, include: { avatar: true }, }); } else { await this.assertActiveUser(existing.id); if (existing.id === guest.id) { targetUser = existing; } else { targetUser = await this.mergeUsers(guest.id, existing.id); } } this.trackSmsUserEvent(targetUser.id, clientApp, 'bind_phone', { phone: this.maskPhone(normalizedPhone), }); return this.buildSessionResponse(targetUser, clientApp, targetUser.deviceKey); } async loginStore(phone: string, code: string, clientApp: ClientApp) { const normalizedPhone = this.assertMobilePhone(phone); await this.smsProvider.verify(normalizedPhone, code, SmsScene.STORE_LOGIN); const account = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone }, include: { store: true }, }); if (!account) throw new BadRequestException('门店账号不存在'); await this.prisma.storeAccount.update({ where: { id: account.id }, data: { lastLoginAt: new Date() }, }); 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 loginPartner(phone: string, code: string, clientApp: ClientApp) { const normalizedPhone = this.assertMobilePhone(phone); await this.smsProvider.verify(normalizedPhone, code, SmsScene.PARTNER_LOGIN); const account = await this.prisma.partnerAccount.findUnique({ where: { phone: normalizedPhone }, include: { partner: true }, }); if (!account) throw new BadRequestException('合伙人账号不存在'); await this.prisma.partnerAccount.update({ where: { id: account.id }, data: { lastLoginAt: new Date() }, }); 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, }); } async loginHq(phone: string, code: string, clientApp: ClientApp) { const normalizedPhone = this.assertMobilePhone(phone); await this.smsProvider.verify(normalizedPhone, code, SmsScene.HQ_LOGIN); const account = await this.prisma.hqAccount.findUnique({ where: { phone: normalizedPhone } }); if (!account) throw new BadRequestException('HQ账号不存在'); if (account.status !== 'ACTIVE') throw new BadRequestException('账号已停用'); await this.prisma.hqAccount.update({ where: { id: account.id }, data: { lastLoginAt: new Date() }, }); return this.issueToken('HQ', account.id, clientApp, false, undefined, undefined, undefined, undefined, { id: account.id.toString(), phone: account.phone, name: account.name, adminRole: account.adminRole, status: account.status, }); } async loginHqPassword(loginName: string, password: string, clientApp: ClientApp) { const normalizedLogin = loginName.trim(); if (!normalizedLogin) throw new BadRequestException('请输入账号'); const account = await this.prisma.hqAccount.findUnique({ where: { loginName: normalizedLogin }, }); if (!account?.passwordHash) throw new BadRequestException('账号或密码错误'); if (account.status !== 'ACTIVE') throw new BadRequestException('账号已停用'); if (!verifyPassword(password, account.passwordHash)) { throw new BadRequestException('账号或密码错误'); } await this.prisma.hqAccount.update({ where: { id: account.id }, data: { lastLoginAt: new Date() }, }); return this.issueToken('HQ', account.id, clientApp, false, undefined, undefined, undefined, undefined, { id: account.id.toString(), phone: account.phone, name: account.name, adminRole: account.adminRole, status: account.status, }); } async getMe(actorType: string, actorId: bigint) { if (actorType === 'USER') { const user = await this.assertActiveUser(actorId); return this.formatUserProfile(user); } if (actorType === 'STORE') { const account = await this.prisma.storeAccount.findUnique({ where: { id: actorId }, include: { store: true }, }); return serializeBigInt(account); } if (actorType === 'PARTNER') { const account = await this.prisma.partnerAccount.findUnique({ where: { id: actorId }, include: { partner: true }, }); return serializeBigInt(account); } if (actorType === 'HQ') { const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId } }); return serializeBigInt(account); } return null; } wechatDisabled() { 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); const 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 }, }); 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, { eventName: 'wechat_login', extraJson: { platform }, }); this.analyticsService.trackOneSafe(activeUser.id, clientApp, { eventName: 'login_success', extraJson: { method: 'wechat', platform }, }); 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(); await this.redis.setJson( `wx:session:${wxSessionKey}`, { openId: session.openId, unionId: session.unionId, sessionKey: 'sessionKey' in session ? session.sessionKey : undefined, accessToken: session.accessToken, 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(); const wxSession = await this.redis.getJson(`wx:session:${wxSessionKey}`); if (!wxSession) throw new BadRequestException('微信会话已过期,请重新授权'); const guestId = wxSession.guestId ? BigInt(wxSession.guestId) : undefined; await this.verifySmsForUser(phone, code, SmsScene.BIND_PHONE, clientApp, guestId); 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('绑定失败'); 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' }, }); this.analyticsService.trackOneSafe(user.id, clientApp, { eventName: 'login_success', extraJson: { method: 'wechat_bind' }, }); 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(`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') { 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 bindPartnerWechat( partnerAccountId: bigint, code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5', ) { this.assertWechatEnabled(); const session = platform === 'mini' ? await this.wechatProvider.code2Session(code) : await this.wechatProvider.oauth2AccessToken(code); const account = await this.prisma.partnerAccount.findUnique({ where: { id: partnerAccountId }, include: { partner: true }, }); if (!account) throw new BadRequestException('合伙人账号不存在'); const conflict = await this.prisma.partnerAccount.findFirst({ where: { wxOpenId: session.openId, id: { not: partnerAccountId } }, }); if (conflict) { throw new BadRequestException('该微信已绑定其他合伙人账号'); } const updated = await this.prisma.partnerAccount.update({ where: { id: partnerAccountId }, data: { wxOpenId: session.openId, wxUnionId: session.unionId ?? account.wxUnionId, lastLoginAt: new Date(), }, include: { partner: true }, }); return this.issueToken('PARTNER', updated.id, clientApp, false, undefined, undefined, { id: updated.id.toString(), partnerId: updated.partnerId.toString(), name: updated.name, phone: updated.phone, isPrimary: updated.isPrimary === 1, companyName: updated.partner.companyName, }); } async loginPartnerWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') { this.assertWechatEnabled(); const session = platform === 'mini' ? await this.wechatProvider.code2Session(code) : await this.wechatProvider.oauth2AccessToken(code); let account = await this.prisma.partnerAccount.findFirst({ where: { wxOpenId: session.openId }, include: { partner: true }, }); if (!account) { throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信'); } account = await this.prisma.partnerAccount.update({ where: { id: account.id }, data: { wxOpenId: session.openId, wxUnionId: session.unionId ?? account.wxUnionId, lastLoginAt: new Date(), }, include: { partner: true }, }); return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, { id: account.id.toString(), partnerId: account.partnerId.toString(), name: account.name, phone: account.phone, isPrimary: account.isPrimary === 1, companyName: account.partner.companyName, }); } private async mergeUsers(guestId: bigint, primaryId: bigint): Promise { if (guestId === primaryId) { return this.assertActiveUser(primaryId); } await this.prisma.$transaction(async (tx) => { const guest = await tx.user.findUnique({ where: { id: guestId } }); const primary = await tx.user.findUnique({ where: { id: primaryId } }); if (!guest || guest.mergedIntoUserId || guest.status !== 1) { throw new BadRequestException('访客账号无效'); } if (!primary || primary.mergedIntoUserId || primary.status !== 1) { throw new BadRequestException('目标账号无效'); } 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 } }); await tx.commonEvent.updateMany({ where: { actorType: 'USER', actorId: guestId }, data: { actorId: primaryId }, }); await tx.redeemRecord.updateMany({ where: { userId: guestId }, data: { userId: primaryId } }); await tx.logUserAnalytics.updateMany({ where: { userId: guestId }, data: { userId: primaryId } }); const primaryPref = await tx.userCityPreference.findUnique({ where: { userId: primaryId } }); const guestPref = await tx.userCityPreference.findUnique({ where: { userId: guestId } }); if (!primaryPref && guestPref) { await tx.userCityPreference.update({ where: { userId: guestId }, data: { userId: primaryId }, }); } else if (guestPref) { await tx.userCityPreference.delete({ where: { userId: guestId } }); } const guestPromo = await tx.userPromoAttribution.findUnique({ where: { userId: guestId } }); if (guestPromo) { const primaryPromo = await tx.userPromoAttribution.findUnique({ where: { userId: primaryId } }); if (primaryPromo) { await tx.userPromoAttribution.delete({ where: { userId: guestId } }); } else { await tx.userPromoAttribution.update({ where: { userId: guestId }, data: { userId: primaryId }, }); } } const deviceKeyToTransfer = guest.deviceKey && !primary.deviceKey ? guest.deviceKey : null; if (deviceKeyToTransfer) { // 先清空访客 deviceKey,避免 uk_user_user_device_key 冲突 await tx.user.update({ where: { id: guestId }, data: { deviceKey: null } }); await tx.user.update({ where: { id: primaryId }, data: { deviceKey: deviceKeyToTransfer } }); } await tx.user.update({ where: { id: guestId }, data: { mergedIntoUserId: primaryId, status: 0, deviceKey: null, }, }); }); await this.userAddressService.normalizeDefaultAddress(primaryId); return this.assertActiveUser(primaryId); } private async assertActiveUser(userId: bigint): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, include: { avatar: true }, }); if (!user) throw new NotFoundException('用户不存在'); if (user.mergedIntoUserId) { throw new UnauthorizedException('账号已合并,请重新进入'); } if (user.status !== 1) { throw new ForbiddenException('账号已停用'); } return user; } private buildSessionResponse(user: UserRow, clientApp: ClientApp, deviceKey: string | null) { const phoneVerified = !!user.phoneVerifiedAt; 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 { 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 { 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) { return { id: user.id.toString(), userNo: user.userNo, phone: user.phone ? user.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : null, phoneVerified: !!user.phoneVerifiedAt, nickname: user.nickname, avatarUrl: user.avatar?.url ?? null, hasWechat: !!user.wxOpenId, }; } private issueToken( actorType: string, actorId: bigint, clientApp: ClientApp, phoneVerified: boolean, user?: Record, store?: Record, partner?: Record, deviceKey?: string | null, hq?: Record, ) { const payload = { sub: actorId.toString(), actorType, actorId: actorId.toString(), clientApp, phoneVerified, }; const accessToken = this.jwtService.sign(payload); const refreshToken = this.jwtService.sign(payload, { expiresIn: '30d' }); return { accessToken, refreshToken, deviceKey: deviceKey ?? undefined, actorType, actorId: actorId.toString(), phoneVerified, user, store, partner, hq, }; } }