1440 lines
48 KiB
TypeScript
1440 lines
48 KiB
TypeScript
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.STORE_ACCOUNT_OPEN:
|
|
return ClientApp.HQ_WEB;
|
|
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<SmsActorRef | undefined> {
|
|
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<string, unknown>,
|
|
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,
|
|
clientApp: ClientApp | string,
|
|
eventName: string,
|
|
extraJson?: Record<string, unknown>,
|
|
ref?: { refType?: string; refId?: bigint },
|
|
) {
|
|
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<string, unknown>,
|
|
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;
|
|
},
|
|
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,
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
async verifySmsCode(phone: string, code: string, scene: SmsScene) {
|
|
const normalizedPhone = this.assertMobilePhone(phone);
|
|
await this.smsProvider.verify(normalizedPhone, code, scene);
|
|
}
|
|
|
|
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, storeId: true },
|
|
});
|
|
if (storeAccount) {
|
|
this.trackStoreEvent(storeAccount.id, storeAccount.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) {
|
|
return this.buildStoreSessionResponse(BigInt(payload.actorId), clientApp);
|
|
}
|
|
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 buildStoreSessionResponse(accountId: bigint, clientApp: ClientApp) {
|
|
const account = await this.prisma.storeAccount.findUnique({
|
|
where: { id: accountId },
|
|
include: { store: true },
|
|
});
|
|
if (!account || account.status !== 'ACTIVE') {
|
|
throw new UnauthorizedException('Invalid refresh token');
|
|
}
|
|
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,
|
|
});
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
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);
|
|
try {
|
|
await this.smsProvider.verify(normalizedPhone, code, SmsScene.STORE_LOGIN);
|
|
} catch (err) {
|
|
const account = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone } });
|
|
if (account) {
|
|
this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_sms_verify_fail', {
|
|
phone: this.maskPhone(normalizedPhone),
|
|
reason: err instanceof BadRequestException ? err.message : '验证码错误',
|
|
});
|
|
}
|
|
throw err;
|
|
}
|
|
const account = await this.prisma.storeAccount.findUnique({
|
|
where: { phone: normalizedPhone },
|
|
include: { store: true },
|
|
});
|
|
if (!account) throw new BadRequestException('该手机号未绑定门店');
|
|
if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用');
|
|
await this.prisma.storeAccount.update({
|
|
where: { id: account.id },
|
|
data: { lastLoginAt: new Date() },
|
|
});
|
|
this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_sms_login', {
|
|
phone: this.maskPhone(normalizedPhone),
|
|
});
|
|
this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_login_success', {
|
|
method: 'sms',
|
|
});
|
|
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);
|
|
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') {
|
|
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 },
|
|
});
|
|
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 },
|
|
});
|
|
|
|
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<WxSessionPayload>(`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<WxSessionPayload>(`wx:session:${input.wxSessionKey}`);
|
|
if (!wxSession) throw new BadRequestException('微信会话已过期,请重新授权');
|
|
openId = wxSession.openId;
|
|
unionId = wxSession.unionId;
|
|
accessToken = wxSession.accessToken;
|
|
await this.redis.del(`wx:session:${input.wxSessionKey}`);
|
|
}
|
|
|
|
const user = await this.attachWechatToUser(
|
|
userId,
|
|
{ openId, unionId, accessToken },
|
|
clientApp,
|
|
platform,
|
|
{ skipLoginEvents: true },
|
|
);
|
|
this.analyticsService.trackOneSafe(userId, clientApp, {
|
|
eventName: 'wechat_bind',
|
|
extraJson: { platform },
|
|
});
|
|
return this.buildSessionResponse(user, clientApp, user.deviceKey);
|
|
}
|
|
|
|
async loginStoreWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') {
|
|
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 },
|
|
});
|
|
|
|
this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_wechat_login', { platform });
|
|
this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_login_success', {
|
|
method: 'wechat',
|
|
platform,
|
|
});
|
|
|
|
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 bindStoreWechat(
|
|
storeAccountId: 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.storeAccount.findUnique({
|
|
where: { id: storeAccountId },
|
|
include: { store: true },
|
|
});
|
|
if (!account) throw new BadRequestException('门店账号不存在');
|
|
|
|
const conflict = await this.prisma.storeAccount.findFirst({
|
|
where: { wxOpenId: session.openId, id: { not: storeAccountId } },
|
|
});
|
|
if (conflict) {
|
|
throw new BadRequestException('该微信已绑定其他门店账号');
|
|
}
|
|
|
|
const updated = await this.prisma.storeAccount.update({
|
|
where: { id: storeAccountId },
|
|
data: {
|
|
wxOpenId: session.openId,
|
|
wxUnionId: session.unionId ?? account.wxUnionId,
|
|
lastLoginAt: new Date(),
|
|
},
|
|
include: { store: true },
|
|
});
|
|
|
|
this.trackStoreEvent(updated.id, updated.storeId, clientApp, 'store_wechat_bind', { platform });
|
|
|
|
return this.issueToken('STORE', updated.id, clientApp, false, undefined, {
|
|
id: updated.id.toString(),
|
|
storeId: updated.storeId.toString(),
|
|
name: updated.name,
|
|
phone: updated.phone,
|
|
storeName: updated.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 },
|
|
});
|
|
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(),
|
|
},
|
|
});
|
|
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 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<UserRow> {
|
|
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<UserRow> {
|
|
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<UserRow | null> {
|
|
try {
|
|
const info = await this.wechatProvider.fetchOAuthUserInfo(accessToken, openId, {
|
|
refType: 'USER',
|
|
refId: userId,
|
|
});
|
|
const current = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
include: { avatar: true },
|
|
});
|
|
if (!current) return null;
|
|
|
|
const data: {
|
|
nickname?: string;
|
|
avatarResourceId?: bigint;
|
|
} = {};
|
|
|
|
if (info.nickname && this.isDefaultNickname(current.nickname)) {
|
|
data.nickname = info.nickname;
|
|
}
|
|
|
|
if (info.headImgUrl && !current.avatarResourceId) {
|
|
const avatar = await this.prisma.commonResource.create({
|
|
data: {
|
|
ownerType: 'USER',
|
|
ownerId: userId,
|
|
bizType: 'AVATAR',
|
|
mediaType: 'IMAGE',
|
|
ossBucket: 'wechat',
|
|
ossKey: `wx-avatar/${openId}`,
|
|
url: info.headImgUrl,
|
|
status: 'ACTIVE',
|
|
},
|
|
});
|
|
data.avatarResourceId = avatar.id;
|
|
}
|
|
|
|
if (!data.nickname && !data.avatarResourceId) {
|
|
return current as UserRow;
|
|
}
|
|
|
|
return this.prisma.user.update({
|
|
where: { id: userId },
|
|
data,
|
|
include: { avatar: true },
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async attachWechatToUser(
|
|
userId: bigint,
|
|
session: { openId: string; unionId?: string; accessToken?: string },
|
|
clientApp: ClientApp,
|
|
platform: string,
|
|
options?: { skipLoginEvents?: boolean },
|
|
): Promise<UserRow> {
|
|
const conflict = await this.prisma.user.findFirst({
|
|
where: {
|
|
wxOpenId: session.openId,
|
|
id: { not: userId },
|
|
status: 1,
|
|
mergedIntoUserId: null,
|
|
},
|
|
});
|
|
if (conflict) {
|
|
throw new BadRequestException('该微信已绑定其他账号');
|
|
}
|
|
|
|
let user = (await this.prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
wxOpenId: session.openId,
|
|
wxUnionId: session.unionId ?? undefined,
|
|
},
|
|
include: { avatar: true },
|
|
})) as UserRow;
|
|
|
|
if (session.accessToken) {
|
|
const synced = await this.syncWechatUserProfile(userId, session.accessToken, session.openId);
|
|
if (synced) user = synced as UserRow;
|
|
}
|
|
|
|
if (!options?.skipLoginEvents) {
|
|
this.analyticsService.trackOneSafe(userId, clientApp, {
|
|
eventName: 'wechat_login',
|
|
extraJson: { platform },
|
|
});
|
|
this.analyticsService.trackOneSafe(userId, clientApp, {
|
|
eventName: 'login_success',
|
|
extraJson: { method: 'wechat', platform },
|
|
});
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
private formatUserProfile(user: UserRow) {
|
|
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<string, unknown>,
|
|
store?: Record<string, unknown>,
|
|
partner?: Record<string, unknown>,
|
|
deviceKey?: string | null,
|
|
hq?: Record<string, unknown>,
|
|
) {
|
|
const payload = {
|
|
sub: actorId.toString(),
|
|
actorType,
|
|
actorId: actorId.toString(),
|
|
clientApp,
|
|
phoneVerified,
|
|
};
|
|
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,
|
|
};
|
|
}
|
|
}
|