feat(mining-admin): 审计日志记录登录失败事件

- audit_logs.adminId 改为可选字段,支持用户名不存在时的失败记录
- 登录失败时记录 LOGIN_FAILED 操作,resourceId 存储尝试的用户名,newValue 记录失败原因
- 前端审计日志页新增 LOGIN_FAILED 标签(红色)及筛选选项
- 新增 migration: 20260310_audit_log_nullable_admin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-10 00:42:57 -07:00
parent e7d852e25e
commit ad1c889848
4 changed files with 15 additions and 5 deletions

View File

@ -0,0 +1,2 @@
-- Make adminId nullable in audit_logs to support LOGIN_FAILED records without a valid admin account
ALTER TABLE "audit_logs" ALTER COLUMN "admin_id" DROP NOT NULL;

View File

@ -85,8 +85,8 @@ model InitializationRecord {
model AuditLog {
id String @id @default(uuid())
adminId String
action String // CREATE, UPDATE, DELETE, LOGIN, LOGOUT, INIT
adminId String?
action String // CREATE, UPDATE, DELETE, LOGIN, LOGIN_FAILED, LOGOUT, INIT
resource String // CONFIG, USER, SYSTEM_ACCOUNT, MINING
resourceId String?
oldValue Json?
@ -95,7 +95,7 @@ model AuditLog {
userAgent String?
createdAt DateTime @default(now())
admin AdminUser @relation(fields: [adminId], references: [id])
admin AdminUser? @relation(fields: [adminId], references: [id])
@@index([adminId])
@@index([action])

View File

@ -23,11 +23,17 @@ export class AuthService {
async login(username: string, password: string, ipAddress?: string, userAgent?: string): Promise<{ token: string; admin: any }> {
const admin = await this.prisma.adminUser.findUnique({ where: { username } });
if (!admin || admin.status !== 'ACTIVE') {
await this.prisma.auditLog.create({
data: { action: 'LOGIN_FAILED', resource: 'AUTH', resourceId: username, newValue: { reason: admin ? 'account_disabled' : 'user_not_found' }, ipAddress, userAgent },
});
throw new UnauthorizedException('Invalid credentials');
}
const isValid = await bcrypt.compare(password, admin.password);
if (!isValid) {
await this.prisma.auditLog.create({
data: { adminId: admin.id, action: 'LOGIN_FAILED', resource: 'AUTH', resourceId: username, newValue: { reason: 'wrong_password' }, ipAddress, userAgent },
});
throw new UnauthorizedException('Invalid credentials');
}

View File

@ -30,8 +30,9 @@ const actionLabels: Record<string, { label: string; className: string }> = {
CREATE: { label: '创建', className: 'bg-green-100 text-green-700' },
UPDATE: { label: '更新', className: 'bg-blue-100 text-blue-700' },
DELETE: { label: '删除', className: 'bg-red-100 text-red-700' },
LOGIN: { label: '登录', className: 'bg-purple-100 text-purple-700' },
LOGOUT: { label: '登出', className: 'bg-gray-100 text-gray-600' },
LOGIN: { label: '登录', className: 'bg-purple-100 text-purple-700' },
LOGIN_FAILED: { label: '登录失败', className: 'bg-red-100 text-red-700' },
LOGOUT: { label: '登出', className: 'bg-gray-100 text-gray-600' },
ENABLE: { label: '启用', className: 'bg-emerald-100 text-emerald-700' },
DISABLE: { label: '禁用', className: 'bg-orange-100 text-orange-700' },
UNLOCK: { label: '解锁', className: 'bg-yellow-100 text-yellow-700' },
@ -93,6 +94,7 @@ export default function AuditLogsPage() {
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="LOGIN"></SelectItem>
<SelectItem value="LOGIN_FAILED"></SelectItem>
<SelectItem value="LOGOUT"></SelectItem>
<SelectItem value="CREATE"></SelectItem>
<SelectItem value="UPDATE"></SelectItem>