feat(identity-service): 添加管理员登录功能
- 新增 AdminAccount 数据表存储管理员账户 - 在 AuthController 添加 POST /auth/login 端点 - 支持邮箱+密码登录,使用 bcrypt 验证 - 在 seed.ts 中初始化默认管理员账户 - 邮箱: admin@rwadurian.com - 密码: Admin@123456 - 前端登录页面适配新的响应格式 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f43124894d
commit
cb40463521
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "admin_accounts" (
|
||||||
|
"id" BIGSERIAL NOT NULL,
|
||||||
|
"email" VARCHAR(100) NOT NULL,
|
||||||
|
"password_hash" VARCHAR(100) NOT NULL,
|
||||||
|
"nickname" VARCHAR(100) NOT NULL,
|
||||||
|
"role" VARCHAR(20) NOT NULL DEFAULT 'admin',
|
||||||
|
"status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"last_login_at" TIMESTAMP(3),
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "admin_accounts_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "admin_accounts_email_key" ON "admin_accounts"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "idx_admin_email" ON "admin_accounts"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "idx_admin_role" ON "admin_accounts"("role");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "idx_admin_status" ON "admin_accounts"("status");
|
||||||
|
|
@ -307,6 +307,27 @@ model ReferralLink {
|
||||||
@@map("referral_links")
|
@@map("referral_links")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 管理员账户表 - 用于后台管理系统登录
|
||||||
|
// ============================================
|
||||||
|
model AdminAccount {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
email String @unique @db.VarChar(100)
|
||||||
|
passwordHash String @map("password_hash") @db.VarChar(100) // bcrypt 哈希
|
||||||
|
nickname String @db.VarChar(100)
|
||||||
|
role String @default("admin") @db.VarChar(20) // super_admin, admin, operator
|
||||||
|
status String @default("ACTIVE") @db.VarChar(20) // ACTIVE, INACTIVE, LOCKED
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
lastLoginAt DateTime? @map("last_login_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([email], name: "idx_admin_email")
|
||||||
|
@@index([role], name: "idx_admin_role")
|
||||||
|
@@index([status], name: "idx_admin_status")
|
||||||
|
@@map("admin_accounts")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Outbox 事件表 - 保证事件可靠发送
|
// Outbox 事件表 - 保证事件可靠发送
|
||||||
// 使用 Outbox Pattern 确保领域事件100%送达
|
// 使用 Outbox Pattern 确保领域事件100%送达
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
|
@ -6,6 +7,19 @@ const prisma = new PrismaClient();
|
||||||
// 系统账户定义
|
// 系统账户定义
|
||||||
// 系统账户使用特殊序列号格式: S + 00000 + 序号 (S0000000001 ~ S0000000004)
|
// 系统账户使用特殊序列号格式: S + 00000 + 序号 (S0000000001 ~ S0000000004)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
// ============================================
|
||||||
|
// 管理员账户定义
|
||||||
|
// 默认管理员密码: Admin@123456
|
||||||
|
// ============================================
|
||||||
|
const ADMIN_ACCOUNTS = [
|
||||||
|
{
|
||||||
|
email: 'admin@rwadurian.com',
|
||||||
|
password: 'Admin@123456',
|
||||||
|
nickname: '超级管理员',
|
||||||
|
role: 'super_admin',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const SYSTEM_ACCOUNTS = [
|
const SYSTEM_ACCOUNTS = [
|
||||||
{
|
{
|
||||||
userId: BigInt(1),
|
userId: BigInt(1),
|
||||||
|
|
@ -76,9 +90,32 @@ async function main() {
|
||||||
console.log(` - Created system account: ${account.nickname} (accountSequence=${account.accountSequence})`);
|
console.log(` - Created system account: ${account.nickname} (accountSequence=${account.accountSequence})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 创建管理员账户
|
||||||
|
console.log('Creating admin accounts...');
|
||||||
|
for (const admin of ADMIN_ACCOUNTS) {
|
||||||
|
const passwordHash = await bcrypt.hash(admin.password, 10);
|
||||||
|
await prisma.adminAccount.upsert({
|
||||||
|
where: { email: admin.email },
|
||||||
|
update: {
|
||||||
|
passwordHash,
|
||||||
|
nickname: admin.nickname,
|
||||||
|
role: admin.role,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
email: admin.email,
|
||||||
|
passwordHash,
|
||||||
|
nickname: admin.nickname,
|
||||||
|
role: admin.role,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(` - Created admin account: ${admin.email} (role=${admin.role})`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Database seeded successfully!');
|
console.log('Database seeded successfully!');
|
||||||
console.log(`- Initialized account sequence generator for date ${dateKey}`);
|
console.log(`- Initialized account sequence generator for date ${dateKey}`);
|
||||||
console.log(`- Created ${SYSTEM_ACCOUNTS.length} system accounts (S0000000001-S0000000004)`);
|
console.log(`- Created ${SYSTEM_ACCOUNTS.length} system accounts (S0000000001-S0000000004)`);
|
||||||
|
console.log(`- Created ${ADMIN_ACCOUNTS.length} admin accounts`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,23 @@
|
||||||
import { Controller, Post, Body } from '@nestjs/common';
|
import { Controller, Post, Body, UnauthorizedException, Logger } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { PrismaService } from '@/infrastructure/persistence/prisma.service';
|
||||||
import { UserApplicationService } from '@/application/services/user-application.service';
|
import { UserApplicationService } from '@/application/services/user-application.service';
|
||||||
import { Public } from '@/shared/guards/jwt-auth.guard';
|
import { Public } from '@/shared/guards/jwt-auth.guard';
|
||||||
import { AutoLoginCommand } from '@/application/commands';
|
import { AutoLoginCommand } from '@/application/commands';
|
||||||
import { AutoLoginDto } from '@/api/dto';
|
import { AutoLoginDto, AdminLoginDto, AdminLoginResponseDto } from '@/api/dto';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
|
||||||
@ApiTags('Auth')
|
@ApiTags('Auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly userService: UserApplicationService) {}
|
private readonly logger = new Logger(AuthController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly userService: UserApplicationService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
|
|
@ -16,4 +25,65 @@ export class AuthController {
|
||||||
async refresh(@Body() dto: AutoLoginDto) {
|
async refresh(@Body() dto: AutoLoginDto) {
|
||||||
return this.userService.autoLogin(new AutoLoginCommand(dto.refreshToken, dto.deviceId));
|
return this.userService.autoLogin(new AutoLoginCommand(dto.refreshToken, dto.deviceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('login')
|
||||||
|
@ApiOperation({ summary: '管理员登录 (邮箱+密码)' })
|
||||||
|
@ApiResponse({ status: 200, type: AdminLoginResponseDto })
|
||||||
|
@ApiResponse({ status: 401, description: '邮箱或密码错误' })
|
||||||
|
async adminLogin(@Body() dto: AdminLoginDto): Promise<AdminLoginResponseDto> {
|
||||||
|
this.logger.log(`[AdminLogin] 尝试登录: ${dto.email}`);
|
||||||
|
|
||||||
|
// 从数据库查找管理员
|
||||||
|
const admin = await this.prisma.adminAccount.findUnique({
|
||||||
|
where: { email: dto.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!admin) {
|
||||||
|
this.logger.warn(`[AdminLogin] 管理员不存在: ${dto.email}`);
|
||||||
|
throw new UnauthorizedException('邮箱或密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查账户状态
|
||||||
|
if (admin.status !== 'ACTIVE') {
|
||||||
|
this.logger.warn(`[AdminLogin] 账户状态异常: ${dto.email}, status=${admin.status}`);
|
||||||
|
throw new UnauthorizedException('账户已被禁用');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码 (使用 bcrypt)
|
||||||
|
const isPasswordValid = await bcrypt.compare(dto.password, admin.passwordHash);
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
this.logger.warn(`[AdminLogin] 密码错误: ${dto.email}`);
|
||||||
|
throw new UnauthorizedException('邮箱或密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新最后登录时间
|
||||||
|
await this.prisma.adminAccount.update({
|
||||||
|
where: { id: admin.id },
|
||||||
|
data: { lastLoginAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生成 JWT Token
|
||||||
|
const payload = {
|
||||||
|
sub: admin.id.toString(),
|
||||||
|
email: admin.email,
|
||||||
|
role: admin.role,
|
||||||
|
type: 'admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
const accessToken = this.jwtService.sign(payload, { expiresIn: '24h' });
|
||||||
|
const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
|
||||||
|
|
||||||
|
this.logger.log(`[AdminLogin] 登录成功: ${dto.email}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: admin.id.toString(),
|
||||||
|
email: admin.email,
|
||||||
|
nickname: admin.nickname,
|
||||||
|
role: admin.role,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,38 @@ export class LoginDto {
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AdminLoginDto {
|
||||||
|
@ApiProperty({ example: 'admin@example.com', description: '管理员邮箱' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '邮箱不能为空' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'password123', description: '密码' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '密码不能为空' })
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminLoginResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '管理员邮箱' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '管理员昵称' })
|
||||||
|
nickname: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '角色' })
|
||||||
|
role: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
accessToken: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdateProfileDto {
|
export class UpdateProfileDto {
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|
|
||||||
|
|
@ -74,30 +74,31 @@ export default function LoginPage() {
|
||||||
|
|
||||||
console.log('[Login] 登录响应:', response);
|
console.log('[Login] 登录响应:', response);
|
||||||
|
|
||||||
// 根据后端返回的数据结构处理
|
// 后端返回格式: { userId, email, nickname, role, accessToken, refreshToken }
|
||||||
const { user, token, accessToken, permissions } = response.data || response;
|
const data = response.data || response;
|
||||||
const finalToken = token || accessToken;
|
const { userId, email, nickname, role, accessToken, refreshToken } = data;
|
||||||
|
|
||||||
if (!finalToken) {
|
if (!accessToken) {
|
||||||
console.error('[Login] 响应中没有 token:', response);
|
console.error('[Login] 响应中没有 accessToken:', response);
|
||||||
throw new Error('登录响应缺少 token');
|
throw new Error('登录响应缺少 token');
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
setCredentials({
|
setCredentials({
|
||||||
user: user || {
|
user: {
|
||||||
id: '1',
|
id: userId,
|
||||||
email: formData.email,
|
email: email,
|
||||||
username: formData.email.split('@')[0],
|
username: email.split('@')[0],
|
||||||
nickname: '管理员',
|
nickname: nickname || '管理员',
|
||||||
avatar: '',
|
avatar: '',
|
||||||
role: 'super_admin',
|
role: role || 'super_admin',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
lastLoginAt: new Date().toISOString(),
|
lastLoginAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
token: finalToken,
|
token: accessToken,
|
||||||
permissions: permissions || ['*'],
|
refreshToken: refreshToken,
|
||||||
|
permissions: ['*'],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue