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:
hailin 2025-12-19 05:17:50 -08:00
parent f43124894d
commit cb40463521
6 changed files with 204 additions and 17 deletions

View File

@ -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");

View File

@ -307,6 +307,27 @@ model ReferralLink {
@@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 Pattern 确保领域事件100%送达

View File

@ -1,4 +1,5 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
@ -6,6 +7,19 @@ const prisma = new PrismaClient();
// 系统账户定义
// 系统账户使用特殊序列号格式: S + 00000 + 序号 (S0000000001 ~ S0000000004)
// ============================================
// ============================================
// 管理员账户定义
// 默认管理员密码: Admin@123456
// ============================================
const ADMIN_ACCOUNTS = [
{
email: 'admin@rwadurian.com',
password: 'Admin@123456',
nickname: '超级管理员',
role: 'super_admin',
},
];
const SYSTEM_ACCOUNTS = [
{
userId: BigInt(1),
@ -76,9 +90,32 @@ async function main() {
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(`- Initialized account sequence generator for date ${dateKey}`);
console.log(`- Created ${SYSTEM_ACCOUNTS.length} system accounts (S0000000001-S0000000004)`);
console.log(`- Created ${ADMIN_ACCOUNTS.length} admin accounts`);
}
main()

View File

@ -1,14 +1,23 @@
import { Controller, Post, Body } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Controller, Post, Body, UnauthorizedException, Logger } from '@nestjs/common';
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 { Public } from '@/shared/guards/jwt-auth.guard';
import { AutoLoginCommand } from '@/application/commands';
import { AutoLoginDto } from '@/api/dto';
import { AutoLoginDto, AdminLoginDto, AdminLoginResponseDto } from '@/api/dto';
import * as bcrypt from 'bcrypt';
@ApiTags('Auth')
@Controller('auth')
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()
@Post('refresh')
@ -16,4 +25,65 @@ export class AuthController {
async refresh(@Body() dto: AutoLoginDto) {
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,
};
}
}

View File

@ -75,6 +75,38 @@ export class LoginDto {
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 {
@ApiPropertyOptional()
@IsOptional()

View File

@ -74,30 +74,31 @@ export default function LoginPage() {
console.log('[Login] 登录响应:', response);
// 根据后端返回的数据结构处理
const { user, token, accessToken, permissions } = response.data || response;
const finalToken = token || accessToken;
// 后端返回格式: { userId, email, nickname, role, accessToken, refreshToken }
const data = response.data || response;
const { userId, email, nickname, role, accessToken, refreshToken } = data;
if (!finalToken) {
console.error('[Login] 响应中没有 token:', response);
if (!accessToken) {
console.error('[Login] 响应中没有 accessToken:', response);
throw new Error('登录响应缺少 token');
}
dispatch(
setCredentials({
user: user || {
id: '1',
email: formData.email,
username: formData.email.split('@')[0],
nickname: '管理员',
user: {
id: userId,
email: email,
username: email.split('@')[0],
nickname: nickname || '管理员',
avatar: '',
role: 'super_admin',
role: role || 'super_admin',
status: 'active',
createdAt: new Date().toISOString(),
lastLoginAt: new Date().toISOString(),
},
token: finalToken,
permissions: permissions || ['*'],
token: accessToken,
refreshToken: refreshToken,
permissions: ['*'],
})
);