import { randomUUID } from 'crypto'; import { BadRequestException, ForbiddenException, forwardRef, 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 { ResourceService } from '../common/resource.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, @Inject(forwardRef(() => ResourceService)) private readonly resourceService: ResourceService, ) {} 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.STORE_ACCOUNT_OPEN: return ClientApp.HQ_WEB; case SmsScene.PARTNER_LOGIN: case SmsScene.PARTNER_STAFF_ADD: case SmsScene.PARTNER_STORE_OPEN: return ClientApp.PARTNER_H5; case SmsScene.HQ_LOGIN: return ClientApp.HQ_WEB; case SmsScene.REDEEM_PHONE_LOOKUP: case SmsScene.REDEEM_PHONE_CONFIRM: return ClientApp.SHOP_H5; case SmsScene.PARTNER_PROXY_ORDER: return ClientApp.PARTNER_H5; 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_STORE_OPEN: { 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; } case SmsScene.REDEEM_PHONE_LOOKUP: case SmsScene.REDEEM_PHONE_CONFIRM: { const user = await this.prisma.user.findFirst({ where: { phone, mergedIntoUserId: null, status: 1 }, select: { id: true }, }); return user ? { refType: 'USER', refId: user.id } : undefined; } case SmsScene.PARTNER_PROXY_ORDER: { const user = await this.prisma.user.findFirst({ where: { phone, mergedIntoUserId: null, status: 1 }, select: { id: true }, }); return user ? { refType: 'USER', refId: user.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 trackStoreEvent( storeAccountId: bigint | undefined, storeId: bigint | undefined, clientApp: ClientApp | string, eventName: string, extraJson?: Record, ref?: { refType?: string; refId?: bigint }, ) { if (storeId == null) return; this.analyticsService.trackStoreOneSafe(storeAccountId, clientApp, { storeId, eventName, refType: ref?.refType, refId: ref?.refId, extraJson, }); } private trackPartnerEvent( actorAccountId: bigint | undefined, primaryAccountId: bigint, clientApp: ClientApp | string, eventName: string, extraJson?: Record, ref?: { refType?: string; refId?: bigint }, ) { this.analyticsService.trackPartnerOneSafe(actorAccountId, clientApp, { partnerAccountId: primaryAccountId, eventName, refType: ref?.refType, refId: ref?.refId, extraJson, }); } private async resolvePrimaryAccount(accountId: bigint) { const account = await this.prisma.partnerAccount.findUnique({ where: { id: accountId } }); if (!account) throw new NotFoundException('合伙人账号不存在'); if (account.isPrimary === 1) return account; if (!account.parentAccountId) { throw new BadRequestException('子账号缺少主账号'); } return this.prisma.partnerAccount.findUniqueOrThrow({ where: { id: account.parentAccountId } }); } private partnerTokenPayload( account: { id: bigint; name: string; phone: string; isPrimary: number; staffRole: string | null; permissions?: unknown; wxOpenId?: string | null; wxNickname?: string | null; wxAvatarUrl?: string | null; }, primary: { id: bigint; companyName: string | null }, ) { return { id: account.id.toString(), primaryAccountId: primary.id.toString(), name: account.name, phone: account.phone, isPrimary: account.isPrimary === 1, staffRole: account.staffRole ?? undefined, companyName: primary.companyName ?? undefined, permissions: Array.isArray(account.permissions) ? account.permissions : undefined, hasWechat: !!account.wxOpenId, wxNickname: account.wxNickname ?? undefined, wxAvatarUrl: account.wxAvatarUrl ?? undefined, }; } /** 公众号 snsapi_userinfo:写入合伙人微信昵称与头像 */ private async syncPartnerWechatProfile( partnerAccountId: bigint, accessToken: string | undefined, openId: string, ) { if (!accessToken) return null; try { const info = await this.wechatProvider.fetchOAuthUserInfo(accessToken, openId, { refType: 'PARTNER', refId: partnerAccountId, }); const data: { wxNickname?: string; wxAvatarUrl?: string } = {}; if (info.nickname?.trim()) data.wxNickname = info.nickname.trim().slice(0, 64); if (info.headImgUrl?.trim()) data.wxAvatarUrl = info.headImgUrl.trim().slice(0, 512); if (!data.wxNickname && !data.wxAvatarUrl) return null; return this.prisma.partnerAccount.update({ where: { id: partnerAccountId }, data, }); } catch { return null; } } private async assertPartnerAccountByPhone(phone: string) { const account = await this.prisma.partnerAccount.findUnique({ where: { phone }, }); if (!account) throw new BadRequestException('未找到合伙人账号'); if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用'); return account; } async checkPartnerPhone(phone: string) { const normalizedPhone = this.assertMobilePhone(phone); const account = await this.assertPartnerAccountByPhone(normalizedPhone); const primary = await this.resolvePrimaryAccount(account.id); return { ok: true, maskedPhone: this.maskPhone(normalizedPhone), name: account.name, companyName: primary.companyName, hasWechat: !!account.wxOpenId, }; } private async assertSmsSendAllowed(phone: string, scene: SmsScene) { if (scene === SmsScene.STORE_LOGIN) { const account = await this.prisma.storeAccount.findUnique({ where: { phone } }); if (!account) throw new BadRequestException('该手机号未绑定门店'); if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用'); return; } if (scene === SmsScene.STORE_ACCOUNT_OPEN) { const existing = await this.prisma.storeAccount.findUnique({ where: { phone } }); if (existing) throw new BadRequestException('该手机号已绑定门店'); return; } if (scene === SmsScene.PARTNER_LOGIN) { await this.assertPartnerAccountByPhone(phone); return; } if (scene === SmsScene.PARTNER_STAFF_ADD) { const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } }); if (existing) throw new BadRequestException('该手机号已被使用'); return; } if (scene === SmsScene.REDEEM_PHONE_LOOKUP || scene === SmsScene.REDEEM_PHONE_CONFIRM) { const user = await this.prisma.user.findFirst({ where: { phone, mergedIntoUserId: null, status: 1 }, select: { id: true, phoneVerifiedAt: true }, }); if (!user) throw new BadRequestException('该手机号未注册好客用户'); if (!user.phoneVerifiedAt) throw new BadRequestException('用户手机号未验证,无法核销'); return; } if (scene === SmsScene.PARTNER_PROXY_ORDER) { return; } if (scene === SmsScene.PARTNER_STORE_OPEN) { return; } } async verifySmsCode(phone: string, code: string, scene: SmsScene) { const normalizedPhone = this.assertMobilePhone(phone); await this.smsProvider.verify(normalizedPhone, code, scene); } /** 合伙人代下单:按手机号查找或创建已验证用户 */ async findOrCreateUserByPhone(phone: string) { const normalizedPhone = this.assertMobilePhone(phone); let user = await this.prisma.user.findUnique({ where: { phone: normalizedPhone }, include: { avatar: true }, }); 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 }, }); } await this.assertActiveUser(user.id); } return user; } 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('无效的验证码场景'); } await this.assertSmsSendAllowed(normalizedPhone, scene as SmsScene); 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 ?? '短信发送失败'); } if (scene === SmsScene.STORE_LOGIN && actorRef?.refType === 'STORE') { const storeAccount = await this.prisma.storeAccount.findUnique({ where: { id: actorRef.refId }, select: { id: true, bindings: { select: { storeId: true }, take: 1 }, }, }); if (storeAccount) { this.trackStoreEvent( storeAccount.id, storeAccount.bindings[0]?.storeId, clientApp, 'store_sms_send', { scene, phone: this.maskPhone(normalizedPhone), status: 'success', }, ); } } if ( (scene === SmsScene.PARTNER_LOGIN || scene === SmsScene.PARTNER_STAFF_ADD) && actorRef?.refType === 'PARTNER' ) { const partnerAccount = await this.prisma.partnerAccount.findUnique({ where: { id: actorRef.refId }, select: { id: true, isPrimary: true, parentAccountId: true }, }); if (partnerAccount) { const primary = await this.resolvePrimaryAccount(partnerAccount.id); this.trackPartnerEvent( partnerAccount.id, primary.id, clientApp, 'partner_sms_send', { scene, phone: this.maskPhone(normalizedPhone), status: 'success', }, ); } } } 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) { throw new UnauthorizedException('Invalid refresh token'); } if (payload.actorType === 'USER') { const user = await this.assertActiveUser(BigInt(payload.actorId)); return this.buildSessionResponse(user, clientApp, user.deviceKey); } if (payload.actorType === 'STORE' && clientApp === ClientApp.SHOP_H5) { const storeId = payload.storeId != null && payload.storeId !== '' ? BigInt(payload.storeId) : undefined; return this.buildStoreSessionResponse(BigInt(payload.actorId), clientApp, storeId); } if (payload.actorType === 'PARTNER' && clientApp === ClientApp.PARTNER_H5) { return this.buildPartnerSessionResponse(BigInt(payload.actorId), clientApp); } throw new UnauthorizedException('Invalid refresh token'); } catch (err) { if (err instanceof UnauthorizedException) throw err; throw new UnauthorizedException('Invalid refresh token'); } } private async loadStoreAccountWithBindings(accountId: bigint) { return this.prisma.storeAccount.findUnique({ where: { id: accountId }, include: { bindings: { include: { store: { select: { id: true, name: true, status: true, district: true, address: true, }, }, }, orderBy: { createdAt: 'asc' }, }, }, }); } private mapShopStoreOptions( bindings: Array<{ store: { id: bigint; name: string; status: string; district: string; address: string; }; }>, ) { return bindings.map((b) => ({ storeId: b.store.id.toString(), name: b.store.name, status: b.store.status, district: b.store.district, address: b.store.address, })); } private formatShopAccountMe(account: { id: bigint; name: string; phone: string; isPrimary: number; staffRole: string | null; permissions: unknown; parentAccountId: bigint | null; wxOpenId: string | null; }) { const permissions = Array.isArray(account.permissions) ? (account.permissions as string[]) : undefined; return { id: account.id.toString(), name: account.name, phone: account.phone, isPrimary: account.isPrimary === 1, staffRole: account.staffRole, permissions, primaryAccountId: account.parentAccountId?.toString(), hasWechat: !!account.wxOpenId, }; } private async buildStoreSessionResponse( accountId: bigint, clientApp: ClientApp, preferredStoreId?: bigint, ) { const account = await this.loadStoreAccountWithBindings(accountId); if (!account || account.status !== 'ACTIVE') { throw new UnauthorizedException('Invalid refresh token'); } return this.issueStoreSession(account, clientApp, preferredStoreId); } private async issueStoreSession( account: NonNullable>>, clientApp: ClientApp, preferredStoreId?: bigint, options?: { autoSelectSingle?: boolean }, ) { const stores = this.mapShopStoreOptions(account.bindings); const autoSelect = options?.autoSelectSingle !== false; let selectedStoreId = preferredStoreId; if (selectedStoreId != null) { const ok = account.bindings.some((b) => b.store.id === selectedStoreId); if (!ok) throw new ForbiddenException('无权访问该门店'); } else if (autoSelect && stores.length === 1) { selectedStoreId = BigInt(stores[0].storeId); } const selected = selectedStoreId ? account.bindings.find((b) => b.store.id === selectedStoreId)?.store : undefined; const accountMe = this.formatShopAccountMe(account); const storePayload = { id: account.id.toString(), storeId: selected?.id.toString() ?? '', name: account.name, phone: account.phone, storeName: selected?.name ?? '', isPrimary: account.isPrimary === 1, stores, }; return this.issueToken( 'STORE', account.id, clientApp, false, undefined, storePayload, undefined, undefined, undefined, selectedStoreId, { account: accountMe, stores, store: selected ? { storeId: selected.id.toString(), name: selected.name, status: selected.status, district: selected.district, address: selected.address, } : null, selectedStoreId: selectedStoreId?.toString(), }, ); } async listShopStores(accountId: bigint) { const account = await this.loadStoreAccountWithBindings(accountId); if (!account || account.status !== 'ACTIVE') { throw new UnauthorizedException('门店账号无效'); } return this.mapShopStoreOptions(account.bindings); } async selectShopStore(accountId: bigint, storeId: bigint, clientApp: ClientApp) { const account = await this.loadStoreAccountWithBindings(accountId); if (!account || account.status !== 'ACTIVE') { throw new UnauthorizedException('门店账号无效'); } const binding = account.bindings.find((b) => b.store.id === storeId); if (!binding) throw new ForbiddenException('无权访问该门店'); this.trackStoreEvent(account.id, storeId, clientApp, 'store_select'); return this.issueStoreSession(account, clientApp, storeId, { autoSelectSingle: false }); } async getShopMe(user: { actorId: bigint; storeId?: bigint }) { const account = await this.loadStoreAccountWithBindings(user.actorId); if (!account) throw new NotFoundException('门店账号不存在'); const stores = this.mapShopStoreOptions(account.bindings); const selected = user.storeId ? account.bindings.find((b) => b.store.id === user.storeId)?.store : undefined; const accountMe = this.formatShopAccountMe(account); return { account: accountMe, stores, store: selected ? { storeId: selected.id.toString(), name: selected.name, status: selected.status, district: selected.district, address: selected.address, } : null, // legacy flat fields for older clients id: account.id.toString(), storeId: selected?.id.toString() ?? '', name: account.name, phone: account.phone, }; } private async buildPartnerSessionResponse(accountId: bigint, clientApp: ClientApp) { const account = await this.prisma.partnerAccount.findUnique({ where: { id: accountId }, }); if (!account || account.status !== 'ACTIVE') { throw new UnauthorizedException('Invalid refresh token'); } const primary = await this.resolvePrimaryAccount(account.id); return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary)); } /** 手机号已验证后建号/登录并签发会话(短信登录与微信手机号快捷登录共用) */ private async issueUserSessionByVerifiedPhone( normalizedPhone: string, clientApp: ClientApp, guestId: bigint | undefined, method: 'sms' | 'wechat_phone', ) { 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); this.analyticsService.trackOneSafe(user.id, clientApp, { eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login', extraJson: { method, accountMerged: true }, }); this.analyticsService.trackOneSafe(user.id, clientApp, { eventName: 'login_success', extraJson: { method, accountMerged: true }, }); return this.buildSessionResponse(user, clientApp, user.deviceKey, { accountMerged: true }); } else { await this.assertActiveUser(user.id); } } if (!user) throw new BadRequestException('登录失败'); this.analyticsService.trackOneSafe(user.id, clientApp, { eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login', extraJson: { method }, }); this.analyticsService.trackOneSafe(user.id, clientApp, { eventName: 'login_success', extraJson: { method }, }); return this.buildSessionResponse(user, clientApp, user.deviceKey); } 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, ); return this.issueUserSessionByVerifiedPhone(normalizedPhone, clientApp, guestId, 'sms'); } /** 小程序 getPhoneNumber:用微信返回的 phoneCode 登录/注册,可选 loginCode 绑定 openId */ async loginUserWechatPhone( phoneCode: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'mini', guestId?: bigint, loginCode?: string, ) { this.assertWechatEnabled(); if (platform !== 'mini') { throw new BadRequestException('仅小程序支持手机号快捷登录'); } const phone = await this.wechatProvider.getPhoneNumberByCode(phoneCode, platform); const normalizedPhone = this.assertMobilePhone(phone); const session = await this.issueUserSessionByVerifiedPhone( normalizedPhone, clientApp, guestId, 'wechat_phone', ); if (loginCode?.trim() && session.actorId) { try { await this.bindUserWechat( BigInt(session.actorId), { code: loginCode.trim() }, clientApp, 'mini', ); } catch { /* 绑定 openId 失败不阻断已成功的手机号登录 */ } } return session; } 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; let accountMerged = false; 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); accountMerged = true; if (!targetUser.phoneVerifiedAt) { targetUser = await this.prisma.user.update({ where: { id: targetUser.id }, data: { phoneVerifiedAt: new Date() }, include: { avatar: true }, }); } } } this.trackSmsUserEvent(targetUser.id, clientApp, 'bind_phone', { phone: this.maskPhone(normalizedPhone), }); return this.buildSessionResponse(targetUser, clientApp, targetUser.deviceKey, { accountMerged, }); } async loginStore(phone: string, code: string, clientApp: ClientApp) { const normalizedPhone = this.assertMobilePhone(phone); try { await this.smsProvider.verify(normalizedPhone, code, SmsScene.STORE_LOGIN); } catch (err) { const account = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone }, include: { bindings: { select: { storeId: true }, take: 1 } }, }); if (account) { this.trackStoreEvent( account.id, account.bindings[0]?.storeId, clientApp, 'store_sms_verify_fail', { phone: this.maskPhone(normalizedPhone), reason: err instanceof BadRequestException ? err.message : '验证码错误', }, ); } throw err; } const found = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone } }); if (!found) throw new BadRequestException('该手机号未绑定门店'); const account = await this.loadStoreAccountWithBindings(found.id); if (!account) throw new BadRequestException('该手机号未绑定门店'); if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用'); if (!account.bindings.length) throw new BadRequestException('该账号未绑定任何门店'); await this.prisma.storeAccount.update({ where: { id: account.id }, data: { lastLoginAt: new Date() }, }); const firstStoreId = account.bindings[0]?.store.id; this.trackStoreEvent(account.id, firstStoreId, clientApp, 'store_sms_login', { phone: this.maskPhone(normalizedPhone), }); this.trackStoreEvent(account.id, firstStoreId, clientApp, 'store_login_success', { method: 'sms', }); return this.issueStoreSession(account, clientApp); } async loginPartner(phone: string, code: string, clientApp: ClientApp) { const normalizedPhone = this.assertMobilePhone(phone); try { await this.smsProvider.verify(normalizedPhone, code, SmsScene.PARTNER_LOGIN); } catch (err) { const account = await this.prisma.partnerAccount.findUnique({ where: { phone: normalizedPhone } }); if (account) { const primary = await this.resolvePrimaryAccount(account.id); this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_sms_verify_fail', { phone: this.maskPhone(normalizedPhone), reason: err instanceof BadRequestException ? err.message : '验证码错误', }); } throw err; } const account = await this.prisma.partnerAccount.findUnique({ where: { phone: normalizedPhone }, }); if (!account) throw new BadRequestException('未找到合伙人账号'); if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用'); const primary = await this.resolvePrimaryAccount(account.id); await this.prisma.partnerAccount.update({ where: { id: account.id }, data: { lastLoginAt: new Date() }, }); this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_sms_login', { phone: this.maskPhone(normalizedPhone), }); this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_login_success', { method: 'sms', }); return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary)); } 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 loginHqWechat(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.hqAccount.findFirst({ where: { wxOpenId: session.openId }, }); if (!account && this.wechatProvider.isMock()) { account = await this.prisma.hqAccount.findFirst({ where: { status: 'ACTIVE' }, orderBy: [{ adminRole: 'asc' }, { id: 'asc' }], }); } if (!account) { throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信'); } if (account.status !== 'ACTIVE') throw new BadRequestException('账号已停用'); account = await this.prisma.hqAccount.update({ where: { id: account.id }, data: { wxOpenId: session.openId, wxUnionId: session.unionId ?? account.wxUnionId, 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') { return this.getShopMe({ actorId }); } if (actorType === 'PARTNER') { const account = await this.prisma.partnerAccount.findUnique({ where: { id: actorId }, }); if (!account) return null; const primary = await this.resolvePrimaryAccount(account.id); return serializeBigInt({ ...account, primaryAccountId: primary.id, companyName: primary.companyName, }); } 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 }, }); // 微信登录不再强制绑定手机号;phoneVerified=false 也可签发会话,下单页仅提示可选绑定 if (user) { 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: { 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, accountMerged }, }); this.analyticsService.trackOneSafe(activeUser.id, clientApp, { eventName: 'login_success', extraJson: { method: 'wechat', platform, accountMerged }, }); 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) { try { const guest = await this.assertActiveUser(guestId); if (!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; } } let created: UserRow = (await this.prisma.user.create({ data: { userNo: generateUserNo(), wxOpenId: session.openId, wxUnionId: session.unionId, nickname: '微信用户', cityPreference: { create: { selectedCityCode: '410100', selectedDistrict: '郑州市', }, }, }, include: { avatar: true }, })) as UserRow; if (session.accessToken) { const synced = await this.syncWechatUserProfile(created.id, session.accessToken, session.openId); if (synced) created = synced as UserRow; } this.analyticsService.trackOneSafe(created.id, clientApp, { eventName: 'wechat_login', extraJson: { platform }, }); this.analyticsService.trackOneSafe(created.id, clientApp, { eventName: 'login_success', extraJson: { method: 'wechat', platform }, }); return this.buildSessionResponse(created, clientApp, created.deviceKey); } 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; 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('手机号已被其他账号占用'); } 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 updateMiniWechatProfile( userId: bigint, input: { nickname?: string; avatarUrl?: string; avatarResourceId?: string }, ) { const user = await this.assertActiveUser(userId); const data: { nickname?: string; avatarResourceId?: bigint; } = {}; const nickname = input.nickname?.trim(); if (nickname) { data.nickname = nickname.slice(0, 64); } const avatarUrl = input.avatarUrl?.trim(); const avatarResourceId = input.avatarResourceId?.trim(); if (avatarResourceId) { let resourceId: bigint; try { resourceId = BigInt(avatarResourceId); } catch { throw new BadRequestException('头像资源编号无效'); } const avatar = await this.resourceService.getOwnedActiveAvatar(resourceId, userId); data.avatarResourceId = avatar.id; } else if (avatarUrl) { // 兼容已发布旧客户端:只接受刚由当前用户上传并登记过的真实资源 URL。 const avatar = await this.resourceService.getOwnedActiveAvatarByUrl(avatarUrl, userId); data.avatarResourceId = avatar.id; } if (!data.nickname && !data.avatarResourceId) { return this.formatUserProfile(user); } const updated = await this.prisma.user.update({ where: { id: userId }, data, include: { avatar: true }, }); return this.formatUserProfile(updated); } 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 }, }); 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(), }, }); const full = await this.loadStoreAccountWithBindings(account.id); if (!full || !full.bindings.length) { throw new BadRequestException('该账号未绑定任何门店'); } const firstStoreId = full.bindings[0]?.store.id; this.trackStoreEvent(account.id, firstStoreId, clientApp, 'store_wechat_login', { platform }); this.trackStoreEvent(account.id, firstStoreId, clientApp, 'store_login_success', { method: 'wechat', platform, }); return this.issueStoreSession(full, clientApp); } async bindStoreWechat( storeAccountId: bigint, code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5', currentStoreId?: bigint, ) { this.assertWechatEnabled(); const session = platform === 'mini' ? await this.wechatProvider.code2Session(code) : await this.wechatProvider.oauth2AccessToken(code); const account = await this.prisma.storeAccount.findUnique({ where: { id: storeAccountId }, }); if (!account) throw new BadRequestException('门店账号不存在'); const conflict = await this.prisma.storeAccount.findFirst({ where: { wxOpenId: session.openId, id: { not: storeAccountId } }, }); if (conflict) { throw new BadRequestException('该微信已绑定其他门店账号'); } await this.prisma.storeAccount.update({ where: { id: storeAccountId }, data: { wxOpenId: session.openId, wxUnionId: session.unionId ?? account.wxUnionId, lastLoginAt: new Date(), }, }); const full = await this.loadStoreAccountWithBindings(storeAccountId); if (!full) throw new BadRequestException('门店账号不存在'); this.trackStoreEvent( storeAccountId, currentStoreId ?? full.bindings[0]?.store.id, clientApp, 'store_wechat_bind', { platform }, ); return this.issueStoreSession(full, clientApp, currentStoreId); } 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 }, }); if (!account) throw new BadRequestException('合伙人账号不存在'); const conflict = await this.prisma.partnerAccount.findFirst({ where: { wxOpenId: session.openId, id: { not: partnerAccountId } }, }); if (conflict) { throw new BadRequestException('该微信已绑定其他合伙人账号'); } let updated = await this.prisma.partnerAccount.update({ where: { id: partnerAccountId }, data: { wxOpenId: session.openId, wxUnionId: session.unionId ?? account.wxUnionId, lastLoginAt: new Date(), }, }); const synced = await this.syncPartnerWechatProfile( updated.id, 'accessToken' in session ? session.accessToken : undefined, session.openId, ); if (synced) updated = synced; const primary = await this.resolvePrimaryAccount(updated.id); this.trackPartnerEvent(updated.id, primary.id, clientApp, 'partner_wechat_bind', { platform }); return this.issueToken('PARTNER', updated.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(updated, primary)); } 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 }, }); if (!account && this.wechatProvider.isMock()) { // preV1 Mock:无绑定微信时回落到演示主账号,方便一键授权登录 account = await this.prisma.partnerAccount.findFirst({ where: { status: 'ACTIVE' }, orderBy: [{ isPrimary: 'desc' }, { id: 'asc' }], }); } 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(), }, }); const synced = await this.syncPartnerWechatProfile( account.id, 'accessToken' in session ? session.accessToken : undefined, session.openId, ); if (synced) account = synced; const primary = await this.resolvePrimaryAccount(account.id); this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_wechat_login', { platform }); this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_login_success', { method: 'wechat', }); return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary)); } 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('目标账号无效'); } // 先迁微信身份到主账号,再清空访客,否则下次 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 } }); 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 }, }); } } if ( guest.sourceType === 'PROMO_CODE' && guest.sourceRefId && primary.sourceType === 'ORGANIC' ) { await tx.user.update({ where: { id: primaryId }, data: { sourceType: 'PROMO_CODE', sourceRefId: guest.sourceRefId, sourceLabel: guest.sourceLabel ?? primary.sourceLabel, }, }); } 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 } }); } 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, }, }); }); 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, extras?: { accountMerged?: boolean }, ) { const phoneVerified = !!user.phoneVerifiedAt; 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) { 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 ?? 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, storeId?: bigint, shopExtra?: Record, ) { const payload = { sub: actorId.toString(), actorType, actorId: actorId.toString(), clientApp, phoneVerified, ...(storeId != null ? { storeId: storeId.toString() } : {}), }; const accessToken = this.jwtService.sign(payload); const refreshExpiresIn = actorType === 'STORE' || actorType === 'PARTNER' ? '7d' : '30d'; const refreshToken = this.jwtService.sign(payload, { expiresIn: refreshExpiresIn }); return { accessToken, refreshToken, deviceKey: deviceKey ?? undefined, actorType, actorId: actorId.toString(), phoneVerified, user, store, partner, hq, ...shopExtra, }; } }