概览页增加后台登录记录

登录成功/失败写入 admin_login_logs,系统概览下方展示全部账号的时间、IP、设备等信息。
This commit is contained in:
yml2213
2026-07-10 21:55:32 +08:00
parent f6ea07912f
commit fa58381aa7
10 changed files with 609 additions and 11 deletions
@@ -0,0 +1,27 @@
-- 002_admin_login_logs.sql —— 后台登录记录。
CREATE TABLE IF NOT EXISTS admin_login_logs (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
username TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
location TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
success BOOLEAN NOT NULL DEFAULT TRUE,
failure_reason TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_admin_login_logs_created_at
ON admin_login_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_admin_login_logs_username
ON admin_login_logs(username);
CREATE INDEX IF NOT EXISTS idx_admin_login_logs_user_id
ON admin_login_logs(user_id);
COMMENT ON TABLE admin_login_logs IS '后台账号登录记录';
COMMENT ON COLUMN admin_login_logs.location IS '登录地点(可选,依赖 IP 推断或代理头)';
COMMENT ON COLUMN admin_login_logs.user_agent IS '客户端设备 / User-Agent';
@@ -0,0 +1,119 @@
import { query } from '../db/client.js'
export type AdminLoginLogRow = {
[column: string]: unknown
id: number
user_id: number | null
username: string
role: string
ip: string
location: string
user_agent: string
success: boolean
failure_reason: string
created_at: string
}
type AdminLoginLogCreateInput = {
userId?: number | string | null
username?: string
role?: string
ip?: string
location?: string
userAgent?: string
success?: boolean
failureReason?: string
createdAt: string
}
type AdminLoginLogListInput = {
username?: string
dateFrom?: string
dateTo?: string
page?: number | string
pageSize?: number | string
}
export async function createAdminLoginLog(
input: AdminLoginLogCreateInput,
): Promise<AdminLoginLogRow | null> {
const result = await query<AdminLoginLogRow>(
`
INSERT INTO admin_login_logs (
user_id,
username,
role,
ip,
location,
user_agent,
success,
failure_reason,
created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *
`,
[
input.userId || null,
String(input.username || '').trim(),
String(input.role || '').trim(),
String(input.ip || '').trim(),
String(input.location || '').trim(),
String(input.userAgent || '').trim(),
input.success !== false,
String(input.failureReason || '').trim(),
input.createdAt,
],
)
return result.rows[0] || null
}
export async function listAdminLoginLogs(
queryInput: AdminLoginLogListInput = {},
): Promise<{ items: AdminLoginLogRow[]; total: number }> {
const conditions: string[] = []
const params: unknown[] = []
if (queryInput.username) {
params.push(String(queryInput.username).trim())
conditions.push(`username = $${params.length}`)
}
if (queryInput.dateFrom) {
params.push(queryInput.dateFrom)
conditions.push(`created_at >= $${params.length}`)
}
if (queryInput.dateTo) {
params.push(queryInput.dateTo)
conditions.push(`created_at <= $${params.length}`)
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
const page = Math.max(1, Number(queryInput.page) || 1)
const pageSize = Math.min(100, Math.max(1, Number(queryInput.pageSize) || 20))
const offset = (page - 1) * pageSize
const totalResult = await query<{ total: number }>(
`SELECT COUNT(*)::int AS total FROM admin_login_logs ${whereClause}`,
params,
)
params.push(pageSize)
params.push(offset)
const itemsResult = await query<AdminLoginLogRow>(
`
SELECT *
FROM admin_login_logs
${whereClause}
ORDER BY id DESC
LIMIT $${params.length - 1} OFFSET $${params.length}
`,
params,
)
return {
items: itemsResult.rows,
total: Number(totalResult.rows[0]?.total || 0),
}
}
+10 -1
View File
@@ -2,6 +2,11 @@ import { Router } from 'express'
import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../../middleware/rate-limit.js'
import { getAdminSessionSummary, loginAdmin } from '../../services/admin/admin-auth-service.js'
import {
resolveClientIp,
resolveClientLocation,
resolveUserAgent,
} from '../../services/admin/admin-login-log-service.js'
import { createJsonHandler, extractBearerToken } from './session.js'
const router = Router()
@@ -12,7 +17,11 @@ router.post('/auth/login', createRateLimitMiddleware({
max: 10,
key: getBodyFieldRateLimitKey('username'),
}), createJsonHandler(
(req) => loginAdmin(req.body?.username, req.body?.password),
(req) => loginAdmin(req.body?.username, req.body?.password, {
ip: resolveClientIp(req),
userAgent: resolveUserAgent(req),
location: resolveClientLocation(req),
}),
{
successMessage: '登录成功',
errorMessage: '后台登录失败',
@@ -1,6 +1,8 @@
import { Router } from 'express'
import type { JsonObject } from '../../types/json.js'
import { getAdminDashboardSummary } from '../../services/admin/admin-dashboard-service.js'
import { getAdminLoginLogs } from '../../services/admin/admin-login-log-service.js'
import { createJsonHandler } from './session.js'
const router = Router()
@@ -14,4 +16,13 @@ router.get('/dashboard/summary', createJsonHandler(
},
))
router.get('/dashboard/login-logs', createJsonHandler(
(req) => getAdminLoginLogs(req.query as JsonObject),
{
successMessage: 'ok',
errorMessage: '读取登录记录失败',
scope: '[admin/dashboard/login-logs]',
},
))
export default router
@@ -13,6 +13,7 @@ import {
import { addHours, nowIso } from '../../utils/time.js'
import { createHttpError } from '../../utils/http.js'
import { normalizePage, normalizePageSize } from './admin-query-utils.js'
import { recordAdminLoginLog } from './admin-login-log-service.js'
type AdminUserRow = NonNullable<Awaited<ReturnType<typeof getAdminUserById>>>
@@ -58,13 +59,27 @@ export async function ensureAdminUsersBootstrapped(): Promise<void> {
}
}
export async function loginAdmin(username: unknown, password: unknown): Promise<JsonObject> {
export async function loginAdmin(
username: unknown,
password: unknown,
meta: {
ip?: string
userAgent?: string
location?: string
} = {},
): Promise<JsonObject> {
ensureAdminAuthConfigured()
const normalizedUsername = String(username || '').trim().toLowerCase()
const normalizedPassword = String(password || '').trim()
if (!normalizedUsername || !normalizedPassword) {
await recordAdminLoginLog({
username: normalizedUsername,
success: false,
failureReason: 'missing_credentials',
...pickLoginMeta(meta),
})
throw createHttpError('缺少后台账号或密码', {
statusCode: 400,
errorCode: 'admin_credentials_required',
@@ -73,13 +88,29 @@ export async function loginAdmin(username: unknown, password: unknown): Promise<
const user = await getAdminUserByUsername(normalizedUsername)
if (!user || user.status !== 'active' || !verifyAdminPassword(normalizedPassword, user.password_hash)) {
await recordAdminLoginLog({
userId: user ? Number(user.id) : null,
username: normalizedUsername,
role: user ? normalizeAdminRole(user.role) : '',
success: false,
failureReason: 'invalid_credentials',
...pickLoginMeta(meta),
})
throw createHttpError('账号或密码错误', {
statusCode: 401,
errorCode: 'admin_login_failed',
})
}
return createAdminSession(user)
const session = createAdminSession(user)
await recordAdminLoginLog({
userId: Number(user.id),
username: String(user.username || normalizedUsername),
role: normalizeAdminRole(user.role),
success: true,
...pickLoginMeta(meta),
})
return session
}
export async function verifyAdminSessionToken(token: unknown): Promise<AdminSession> {
@@ -523,3 +554,27 @@ function mapAdminUser(user: AdminUserRow): JsonObject {
updatedAt: user.updated_at,
}
}
function pickLoginMeta(meta: {
ip?: string
userAgent?: string
location?: string
} = {}) {
const result: {
ip?: string
userAgent?: string
location?: string
} = {}
if (typeof meta.ip === 'string' && meta.ip.trim()) {
result.ip = meta.ip.trim()
}
if (typeof meta.userAgent === 'string' && meta.userAgent.trim()) {
result.userAgent = meta.userAgent.trim()
}
if (typeof meta.location === 'string' && meta.location.trim()) {
result.location = meta.location.trim()
}
return result
}
@@ -0,0 +1,133 @@
import type { Request } from 'express'
import type { JsonObject } from '../../types/json.js'
import {
createAdminLoginLog,
listAdminLoginLogs,
} from '../../repositories/admin-login-log-repo.js'
import { logWarn } from '../../utils/logger.js'
import { nowIso } from '../../utils/time.js'
import {
normalizeDateQuery,
normalizePage,
normalizePageSize,
} from './admin-query-utils.js'
type RecordAdminLoginInput = {
userId?: number | null
username?: string
role?: string
success?: boolean
failureReason?: string
ip?: string
location?: string
userAgent?: string
}
export async function recordAdminLoginLog(input: RecordAdminLoginInput = {}) {
try {
return await createAdminLoginLog({
userId: input.userId ?? null,
username: String(input.username || '').trim(),
role: String(input.role || '').trim(),
ip: String(input.ip || '').trim(),
location: String(input.location || '').trim(),
userAgent: String(input.userAgent || '').trim(),
success: input.success !== false,
failureReason: String(input.failureReason || '').trim(),
createdAt: nowIso(),
})
} catch (error) {
logWarn('[admin/login-log]', '写入登录记录失败,已跳过', {
username: input.username,
error,
})
return null
}
}
export async function recordAdminLoginFromRequest(
req: Request,
input: Omit<RecordAdminLoginInput, 'ip' | 'userAgent' | 'location'> = {},
) {
return recordAdminLoginLog({
...input,
ip: resolveClientIp(req),
userAgent: resolveUserAgent(req),
location: resolveClientLocation(req),
})
}
export async function getAdminLoginLogs(query: JsonObject = {}) {
const page = normalizePage(query.page)
const pageSize = normalizePageSize(query.pageSize)
const { items, total } = await listAdminLoginLogs({
page,
pageSize,
username: String(query.username || '').trim(),
dateFrom: normalizeDateQuery(query.dateFrom),
dateTo: normalizeDateQuery(query.dateTo, true),
})
return {
items: items.map((item) => ({
logId: Number(item.id),
userId: Number(item.user_id || 0) || null,
username: String(item.username || '').trim(),
role: String(item.role || '').trim(),
ip: String(item.ip || '').trim(),
location: String(item.location || '').trim(),
userAgent: String(item.user_agent || '').trim(),
success: item.success !== false,
failureReason: String(item.failure_reason || '').trim(),
createdAt: item.created_at,
})),
pagination: { page, pageSize, total },
}
}
export function resolveClientIp(req: Request) {
const forwarded = String(req.headers['x-forwarded-for'] || '')
.split(',')
.map((item) => item.trim())
.find(Boolean)
const realIp = String(req.headers['x-real-ip'] || '').trim()
const ip = String(forwarded || realIp || req.ip || req.socket?.remoteAddress || '').trim()
return ip.replace(/^::ffff:/, '')
}
export function resolveUserAgent(req: Request) {
return String(req.headers['user-agent'] || '').trim()
}
/** 优先读常见代理/CDN 地理位置头,没有则留空。 */
export function resolveClientLocation(req: Request) {
const country = firstHeader(req, [
'cf-ipcountry',
'x-vercel-ip-country',
'cloudfront-viewer-country',
'x-country-code',
])
const city = firstHeader(req, [
'cf-ipcity',
'x-vercel-ip-city',
'x-city',
])
const region = firstHeader(req, [
'cf-region',
'x-vercel-ip-country-region',
'x-region',
])
return [country, region, city].filter(Boolean).join(' · ')
}
function firstHeader(req: Request, names: string[]) {
for (const name of names) {
const value = String(req.headers[name] || '').trim()
if (value && value.toUpperCase() !== 'XX') {
return value
}
}
return ''
}