2026-05-13 17:31:50 +08:00
|
|
|
import { Injectable, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
2026-05-09 18:57:33 +08:00
|
|
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
2026-05-13 17:31:50 +08:00
|
|
|
import { AppleAuthService } from './apple-auth.service';
|
|
|
|
|
import { TokenService } from './token.service';
|
|
|
|
|
import type { AppleLoginDto, DevLoginDto } from './dto';
|
2026-05-09 18:25:04 +08:00
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class AuthService {
|
2026-05-09 18:57:33 +08:00
|
|
|
constructor(
|
|
|
|
|
private readonly prisma: PrismaService,
|
2026-05-13 17:31:50 +08:00
|
|
|
private readonly appleAuthService: AppleAuthService,
|
|
|
|
|
private readonly tokenService: TokenService,
|
2026-05-09 18:57:33 +08:00
|
|
|
) {}
|
2026-05-09 18:25:04 +08:00
|
|
|
|
2026-05-13 17:31:50 +08:00
|
|
|
async devLogin(dto: DevLoginDto) {
|
|
|
|
|
if (process.env.NODE_ENV === 'production') {
|
|
|
|
|
throw new ForbiddenException('dev-login is disabled in production');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const devSecret = process.env.DEV_SECRET;
|
|
|
|
|
if (!devSecret || dto.devSecret !== devSecret) {
|
|
|
|
|
throw new UnauthorizedException('devSecret 无效');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const providerUserId = dto.email;
|
2026-05-09 18:57:33 +08:00
|
|
|
|
|
|
|
|
let account = await this.prisma.authAccount.findUnique({
|
2026-05-13 17:31:50 +08:00
|
|
|
where: {
|
|
|
|
|
provider_providerUserId: {
|
|
|
|
|
provider: 'DEV',
|
|
|
|
|
providerUserId,
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-05-09 18:57:33 +08:00
|
|
|
include: { user: true },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!account) {
|
|
|
|
|
account = await this.prisma.authAccount.create({
|
|
|
|
|
data: {
|
2026-05-13 17:31:50 +08:00
|
|
|
provider: 'DEV',
|
|
|
|
|
providerUserId,
|
|
|
|
|
email: dto.email,
|
2026-05-09 18:57:33 +08:00
|
|
|
user: {
|
|
|
|
|
create: {
|
2026-05-13 17:31:50 +08:00
|
|
|
email: dto.email,
|
|
|
|
|
nickname: dto.nickname || '测试用户',
|
2026-05-09 18:57:33 +08:00
|
|
|
status: 'active',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
include: { user: true },
|
2026-05-09 18:25:04 +08:00
|
|
|
});
|
|
|
|
|
}
|
2026-05-09 18:57:33 +08:00
|
|
|
|
2026-05-13 17:31:50 +08:00
|
|
|
return this.buildLoginResponse(account.user);
|
|
|
|
|
}
|
2026-05-09 18:57:33 +08:00
|
|
|
|
2026-05-13 17:31:50 +08:00
|
|
|
async appleLogin(dto: AppleLoginDto) {
|
|
|
|
|
const { appleUserId, email: appleEmail } =
|
|
|
|
|
await this.appleAuthService.verifyIdentityToken(dto.identityToken);
|
2026-05-09 18:57:33 +08:00
|
|
|
|
2026-05-13 17:31:50 +08:00
|
|
|
let account = await this.prisma.authAccount.findUnique({
|
|
|
|
|
where: {
|
|
|
|
|
provider_providerUserId: {
|
|
|
|
|
provider: 'APPLE',
|
|
|
|
|
providerUserId: appleUserId,
|
|
|
|
|
},
|
2026-05-09 18:57:33 +08:00
|
|
|
},
|
2026-05-13 17:31:50 +08:00
|
|
|
include: { user: true },
|
2026-05-09 18:57:33 +08:00
|
|
|
});
|
|
|
|
|
|
2026-05-13 17:31:50 +08:00
|
|
|
if (!account) {
|
|
|
|
|
const displayName =
|
|
|
|
|
dto.fullName?.givenName
|
|
|
|
|
? `${dto.fullName.familyName || ''}${dto.fullName.givenName}`
|
|
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
account = await this.prisma.authAccount.create({
|
|
|
|
|
data: {
|
|
|
|
|
provider: 'APPLE',
|
|
|
|
|
providerUserId: appleUserId,
|
|
|
|
|
email: appleEmail || dto.email || null,
|
|
|
|
|
user: {
|
|
|
|
|
create: {
|
|
|
|
|
email: appleEmail || dto.email || null,
|
|
|
|
|
nickname: displayName || undefined,
|
|
|
|
|
status: 'active',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
include: { user: true },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.buildLoginResponse(account.user);
|
2026-05-09 18:25:04 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async refresh(refreshToken: string) {
|
2026-05-13 17:31:50 +08:00
|
|
|
const hash = this.tokenService.hashToken(refreshToken);
|
2026-05-09 18:57:33 +08:00
|
|
|
const stored = await this.prisma.refreshToken.findFirst({
|
|
|
|
|
where: { tokenHash: hash, revokedAt: null },
|
|
|
|
|
include: { user: true },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!stored || stored.expiresAt < new Date()) {
|
|
|
|
|
throw new UnauthorizedException('刷新令牌无效或已过期');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.prisma.refreshToken.update({
|
|
|
|
|
where: { id: stored.id },
|
|
|
|
|
data: { revokedAt: new Date() },
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-13 17:31:50 +08:00
|
|
|
const { token: newRefreshToken, hash: newHash } =
|
|
|
|
|
this.tokenService.generateRefreshToken();
|
2026-05-09 18:57:33 +08:00
|
|
|
|
|
|
|
|
await this.prisma.refreshToken.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: stored.userId,
|
|
|
|
|
tokenHash: newHash,
|
|
|
|
|
expiresAt: new Date(Date.now() + 7 * 86400000),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-13 17:31:50 +08:00
|
|
|
const accessToken = await this.tokenService.generateAccessToken(
|
|
|
|
|
stored.user,
|
|
|
|
|
);
|
2026-05-09 18:57:33 +08:00
|
|
|
|
2026-05-13 17:31:50 +08:00
|
|
|
return {
|
|
|
|
|
accessToken,
|
|
|
|
|
refreshToken: newRefreshToken,
|
|
|
|
|
user: this.serializeUser(stored.user),
|
|
|
|
|
};
|
2026-05-09 18:57:33 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-17 00:39:46 +08:00
|
|
|
async logout(userId: string, refreshToken: string) {
|
2026-05-13 17:31:50 +08:00
|
|
|
const hash = this.tokenService.hashToken(refreshToken);
|
|
|
|
|
const stored = await this.prisma.refreshToken.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
tokenHash: hash,
|
2026-05-17 00:39:46 +08:00
|
|
|
userId,
|
2026-05-13 17:31:50 +08:00
|
|
|
revokedAt: null,
|
|
|
|
|
},
|
2026-05-09 18:57:33 +08:00
|
|
|
});
|
|
|
|
|
|
2026-05-13 17:31:50 +08:00
|
|
|
if (stored) {
|
|
|
|
|
await this.prisma.refreshToken.update({
|
|
|
|
|
where: { id: stored.id },
|
|
|
|
|
data: { revokedAt: new Date() },
|
|
|
|
|
});
|
2026-05-13 15:35:41 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 17:31:50 +08:00
|
|
|
private async buildLoginResponse(user: {
|
2026-05-17 00:39:46 +08:00
|
|
|
id: string;
|
2026-05-13 17:31:50 +08:00
|
|
|
email: string | null;
|
|
|
|
|
nickname: string | null;
|
|
|
|
|
avatarUrl: string | null;
|
|
|
|
|
role: string;
|
|
|
|
|
status: string;
|
|
|
|
|
onboardingCompleted: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const accessToken = await this.tokenService.generateAccessToken(user);
|
2026-05-13 15:35:41 +08:00
|
|
|
|
2026-05-13 17:31:50 +08:00
|
|
|
const { token: refreshToken, hash } =
|
|
|
|
|
this.tokenService.generateRefreshToken();
|
2026-05-13 15:35:41 +08:00
|
|
|
|
2026-05-13 17:31:50 +08:00
|
|
|
await this.prisma.refreshToken.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: user.id,
|
|
|
|
|
tokenHash: hash,
|
|
|
|
|
expiresAt: new Date(Date.now() + 7 * 86400000),
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-05-13 15:35:41 +08:00
|
|
|
|
2026-05-13 17:31:50 +08:00
|
|
|
return {
|
|
|
|
|
accessToken,
|
|
|
|
|
refreshToken,
|
|
|
|
|
user: this.serializeUser(user),
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-05-13 15:35:41 +08:00
|
|
|
|
2026-05-13 17:31:50 +08:00
|
|
|
private serializeUser(user: {
|
2026-05-17 00:39:46 +08:00
|
|
|
id: string;
|
2026-05-13 17:31:50 +08:00
|
|
|
email: string | null;
|
|
|
|
|
nickname: string | null;
|
|
|
|
|
avatarUrl: string | null;
|
|
|
|
|
role: string;
|
|
|
|
|
status: string;
|
|
|
|
|
onboardingCompleted: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
return {
|
2026-05-17 00:39:46 +08:00
|
|
|
id: user.id,
|
2026-05-13 17:31:50 +08:00
|
|
|
email: user.email,
|
|
|
|
|
nickname: user.nickname,
|
|
|
|
|
avatarUrl: user.avatarUrl,
|
|
|
|
|
role: user.role,
|
|
|
|
|
status: user.status,
|
|
|
|
|
onboardingCompleted: user.onboardingCompleted,
|
|
|
|
|
};
|
2026-05-09 18:25:04 +08:00
|
|
|
}
|
|
|
|
|
}
|