用户静默注册

This commit is contained in:
2026-07-01 02:48:59 +08:00
parent d3dd3a0ad1
commit 9f4577d3d8
18 changed files with 912 additions and 93 deletions
+260 -22
View File
@@ -1,8 +1,12 @@
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';
@@ -11,6 +15,23 @@ import { PrismaService } from '../../common/prisma/prisma.module';
import { SMS_PROVIDER } from '../../integrations/integrations.constants';
import { ISmsProvider } from '../../integrations/sms/sms.interface';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { Prisma } from '@prisma/client';
import type { User } from '@prisma/client';
type UserRow = Pick<
User,
| 'id'
| 'userNo'
| 'deviceKey'
| 'phone'
| 'phoneVerifiedAt'
| 'nickname'
| 'avatarUrl'
| 'wxOpenId'
| 'mergedIntoUserId'
| 'status'
>;
@Injectable()
export class AuthService {
@@ -25,28 +46,144 @@ export class AuthService {
return { sent: true };
}
async loginUser(phone: string, code: string, clientApp: ClientApp) {
await this.smsProvider.verify(phone, code, SmsScene.USER_LOGIN);
let user = await this.prisma.user.findUnique({ where: { phone } });
if (!user) {
user = await this.prisma.user.create({
data: {
phone,
userNo: generateUserNo(),
nickname: `用户${phone.slice(-4)}`,
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,
},
});
await this.prisma.userCityPreference.create({
data: { userId: user.id, selectedCityCode: '410100', selectedDistrict: '郑州市' },
}
if (!user) {
resolvedDeviceKey = randomUUID();
user = await this.prisma.user.create({
data: {
userNo: generateUserNo(),
deviceKey: resolvedDeviceKey,
nickname: '访客',
cityPref: {
create: {
selectedCityCode: '410100',
selectedDistrict: '郑州市',
},
},
},
});
}
return this.issueToken('USER', user.id, clientApp, {
id: user.id.toString(),
userNo: user.userNo,
phone: user.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
nickname: user.nickname,
hasWechat: !!user.wxOpenId,
});
return this.buildSessionResponse(user, clientApp, resolvedDeviceKey);
}
async refreshAccessToken(refreshToken: string, clientApp: ClientApp) {
try {
const payload = this.jwtService.verify(refreshToken);
if (payload.clientApp !== clientApp || payload.actorType !== 'USER') {
throw new UnauthorizedException('Invalid refresh token');
}
const user = await this.assertActiveUser(BigInt(payload.actorId));
return this.buildSessionResponse(user, clientApp, user.deviceKey);
} catch (err) {
if (err instanceof UnauthorizedException) throw err;
throw new UnauthorizedException('Invalid refresh token');
}
}
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
await this.smsProvider.verify(phone, code, SmsScene.USER_LOGIN);
let user: UserRow | null = await this.prisma.user.findUnique({ where: { phone } });
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,
phoneVerifiedAt: new Date(),
nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname,
},
});
}
} catch {
/* guest invalid, fall through to create */
}
}
if (!user) {
user = await this.prisma.user.create({
data: {
phone,
phoneVerifiedAt: new Date(),
userNo: generateUserNo(),
nickname: `用户${phone.slice(-4)}`,
cityPref: {
create: {
selectedCityCode: '410100',
selectedDistrict: '郑州市',
},
},
},
});
}
} else {
if (!user.phoneVerifiedAt) {
user = await this.prisma.user.update({
where: { id: user.id },
data: { phoneVerifiedAt: new Date() },
});
}
if (guestId && guestId !== user.id) {
user = await this.mergeUsers(guestId, user.id);
} else {
await this.assertActiveUser(user.id);
}
}
if (!user) throw new BadRequestException('登录失败');
return this.buildSessionResponse(user, clientApp, user.deviceKey);
}
async bindPhone(actorId: bigint, phone: string, code: string, clientApp: ClientApp) {
await this.smsProvider.verify(phone, code, SmsScene.BIND_PHONE);
const guest = await this.assertActiveUser(actorId);
if (guest.phone && guest.phoneVerifiedAt) {
if (guest.phone === phone) {
return this.buildSessionResponse(guest, clientApp, guest.deviceKey);
}
throw new BadRequestException('当前账号已绑定其他手机号');
}
const existing = await this.prisma.user.findUnique({ where: { phone } });
let targetUser: UserRow;
if (!existing) {
targetUser = await this.prisma.user.update({
where: { id: guest.id },
data: {
phone,
phoneVerifiedAt: new Date(),
nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname,
},
});
} else {
await this.assertActiveUser(existing.id);
if (existing.id === guest.id) {
targetUser = existing;
} else {
targetUser = await this.mergeUsers(guest.id, existing.id);
}
}
return this.buildSessionResponse(targetUser, clientApp, targetUser.deviceKey);
}
async loginStore(phone: string, code: string, clientApp: ClientApp) {
@@ -60,7 +197,7 @@ export class AuthService {
where: { id: account.id },
data: { lastLoginAt: new Date() },
});
return this.issueToken('STORE', account.id, clientApp, undefined, {
return this.issueToken('STORE', account.id, clientApp, false, undefined, {
id: account.id.toString(),
storeId: account.storeId.toString(),
name: account.name,
@@ -80,7 +217,7 @@ export class AuthService {
where: { id: account.id },
data: { lastLoginAt: new Date() },
});
return this.issueToken('PARTNER', account.id, clientApp, undefined, undefined, {
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
id: account.id.toString(),
partnerId: account.partnerId.toString(),
name: account.name,
@@ -92,8 +229,8 @@ export class AuthService {
async getMe(actorType: string, actorId: bigint) {
if (actorType === 'USER') {
const user = await this.prisma.user.findUnique({ where: { id: actorId } });
return serializeBigInt(user);
const user = await this.assertActiveUser(actorId);
return this.formatUserProfile(user);
}
if (actorType === 'STORE') {
const account = await this.prisma.storeAccount.findUnique({
@@ -116,27 +253,128 @@ export class AuthService {
throw new NotImplementedException('FEATURE_DISABLED');
}
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.benefitLedger.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
await tx.redeemRecord.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
await tx.eventLog.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 primaryUpdate: Prisma.UserUpdateInput = {};
if (guest.deviceKey && !primary.deviceKey) {
primaryUpdate.deviceKey = guest.deviceKey;
}
if (Object.keys(primaryUpdate).length > 0) {
await tx.user.update({ where: { id: primaryId }, data: primaryUpdate });
}
await tx.user.update({
where: { id: guestId },
data: {
mergedIntoUserId: primaryId,
status: 0,
deviceKey: null,
},
});
});
return this.assertActiveUser(primaryId);
}
private async assertActiveUser(userId: bigint): Promise<UserRow> {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
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 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.avatarUrl,
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,
) {
const payload = {
sub: actorId.toString(),
actorType,
actorId: actorId.toString(),
clientApp,
phoneVerified,
};
const accessToken = this.jwtService.sign(payload);
const refreshToken = this.jwtService.sign(payload, { expiresIn: '30d' });
return {
accessToken,
refreshToken,
deviceKey: deviceKey ?? undefined,
actorType,
actorId: actorId.toString(),
phoneVerified,
user,
store,
partner,