From fa58381aa763164c49bb827ac6c38155648048a9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 10 Jul 2026 21:55:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A6=82=E8=A7=88=E9=A1=B5=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E7=99=BB=E5=BD=95=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 登录成功/失败写入 admin_login_logs,系统概览下方展示全部账号的时间、IP、设备等信息。 --- .../db/migrations/002_admin_login_logs.sql | 27 +++ .../src/repositories/admin-login-log-repo.ts | 119 ++++++++++ apps/backend/src/routes/admin/auth.ts | 11 +- apps/backend/src/routes/admin/dashboard.ts | 11 + .../src/services/admin/admin-auth-service.ts | 59 ++++- .../services/admin/admin-login-log-service.ts | 133 +++++++++++ .../src/pages/admin/AdminDashboardPage.tsx | 215 +++++++++++++++++- apps/frontend/src/services/admin/dashboard.ts | 21 +- apps/frontend/src/types/admin/index.ts | 3 + apps/frontend/src/types/admin/login-logs.ts | 21 ++ 10 files changed, 609 insertions(+), 11 deletions(-) create mode 100644 apps/backend/src/db/migrations/002_admin_login_logs.sql create mode 100644 apps/backend/src/repositories/admin-login-log-repo.ts create mode 100644 apps/backend/src/services/admin/admin-login-log-service.ts create mode 100644 apps/frontend/src/types/admin/login-logs.ts diff --git a/apps/backend/src/db/migrations/002_admin_login_logs.sql b/apps/backend/src/db/migrations/002_admin_login_logs.sql new file mode 100644 index 00000000..0de017ab --- /dev/null +++ b/apps/backend/src/db/migrations/002_admin_login_logs.sql @@ -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'; diff --git a/apps/backend/src/repositories/admin-login-log-repo.ts b/apps/backend/src/repositories/admin-login-log-repo.ts new file mode 100644 index 00000000..2d901a76 --- /dev/null +++ b/apps/backend/src/repositories/admin-login-log-repo.ts @@ -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 { + const result = await query( + ` + 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( + ` + 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), + } +} diff --git a/apps/backend/src/routes/admin/auth.ts b/apps/backend/src/routes/admin/auth.ts index dd039764..86a1925f 100644 --- a/apps/backend/src/routes/admin/auth.ts +++ b/apps/backend/src/routes/admin/auth.ts @@ -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: '后台登录失败', diff --git a/apps/backend/src/routes/admin/dashboard.ts b/apps/backend/src/routes/admin/dashboard.ts index 5ffc5d69..4c89db47 100644 --- a/apps/backend/src/routes/admin/dashboard.ts +++ b/apps/backend/src/routes/admin/dashboard.ts @@ -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 diff --git a/apps/backend/src/services/admin/admin-auth-service.ts b/apps/backend/src/services/admin/admin-auth-service.ts index d9aeaec8..2847c1bb 100644 --- a/apps/backend/src/services/admin/admin-auth-service.ts +++ b/apps/backend/src/services/admin/admin-auth-service.ts @@ -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>> @@ -58,13 +59,27 @@ export async function ensureAdminUsersBootstrapped(): Promise { } } -export async function loginAdmin(username: unknown, password: unknown): Promise { +export async function loginAdmin( + username: unknown, + password: unknown, + meta: { + ip?: string + userAgent?: string + location?: string + } = {}, +): Promise { 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 { @@ -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 +} diff --git a/apps/backend/src/services/admin/admin-login-log-service.ts b/apps/backend/src/services/admin/admin-login-log-service.ts new file mode 100644 index 00000000..0e60ff4c --- /dev/null +++ b/apps/backend/src/services/admin/admin-login-log-service.ts @@ -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 = {}, +) { + 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 '' +} diff --git a/apps/frontend/src/pages/admin/AdminDashboardPage.tsx b/apps/frontend/src/pages/admin/AdminDashboardPage.tsx index e64e58eb..cd54ecc7 100644 --- a/apps/frontend/src/pages/admin/AdminDashboardPage.tsx +++ b/apps/frontend/src/pages/admin/AdminDashboardPage.tsx @@ -1,8 +1,34 @@ -import { Alert, Card, Col, Row, Skeleton, Statistic } from 'antd' +import { ReloadOutlined, SearchOutlined } from '@ant-design/icons' +import { + Alert, + Button, + Card, + Col, + Form, + Input, + Row, + Skeleton, + Space, + Statistic, + Table, + Tag, + Typography, +} from 'antd' +import type { TableColumnsType } from 'antd' import { useQuery } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import { AdminRangePicker } from '@/components/admin/AdminDatePicker' import PageHeader from '@/components/admin/PageHeader' -import { fetchAdminDashboardSummary } from '@/services/admin' +import StatusTag from '@/components/admin/StatusTag' +import dayjs from '@/lib/dayjs' +import { fetchAdminDashboardSummary, fetchAdminLoginLogs } from '@/services/admin' +import type { AdminLoginLogItem } from '@/types/admin' +import { + ADMIN_DEFAULT_PAGE_SIZE, + buildAdminTablePagination, +} from '@/utils/admin-pagination' +import { formatAdminDateTime } from '@/utils/admin-time' const cards = [ { key: 'todayOrders', label: '今日订单' }, @@ -12,26 +38,124 @@ const cards = [ { key: 'abnormalTasks', label: '异常任务' }, ] as const +type LoginFilterForm = { + username?: string + dateRange?: unknown +} + export default function AdminDashboardPage() { - const query = useQuery({ + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) + const [filters, setFilters] = useState({ + username: '', + dateFrom: '', + dateTo: '', + }) + + const summaryQuery = useQuery({ queryKey: ['admin-dashboard-summary'], queryFn: () => fetchAdminDashboardSummary(), }) - const summary = query.data?.data + const summary = summaryQuery.data?.data + + const loginQueryParams = useMemo( + () => ({ + page, + pageSize, + username: filters.username || undefined, + dateFrom: filters.dateFrom || undefined, + dateTo: filters.dateTo || undefined, + }), + [filters.dateFrom, filters.dateTo, filters.username, page, pageSize], + ) + + const loginQuery = useQuery({ + queryKey: ['admin-login-logs', loginQueryParams], + queryFn: () => fetchAdminLoginLogs(loginQueryParams), + }) + const loginData = loginQuery.data?.data + + const columns: TableColumnsType = [ + { + title: '登录时间', + dataIndex: 'createdAt', + width: 180, + render: (value) => formatAdminDateTime(value), + }, + { + title: '账号', + dataIndex: 'username', + minWidth: 140, + render: (value, row) => ( +
+ {value || '-'} + {row.role ? : null} +
+ ), + }, + { + title: '结果', + width: 100, + render: (_, row) => + row.success ? ( + 成功 + ) : ( + 失败 + ), + }, + { + title: '登录地点', + dataIndex: 'location', + minWidth: 140, + render: (value) => value || '-', + }, + { + title: '登录 IP', + dataIndex: 'ip', + width: 150, + render: (value) => value || '-', + }, + { + title: '设备信息', + dataIndex: 'userAgent', + minWidth: 280, + render: (value) => ( + + {value || '-'} + + ), + }, + ] + + function applyLoginFilters(values: LoginFilterForm) { + const range = Array.isArray(values.dateRange) ? values.dateRange : [] + const dateFrom = + range[0] && dayjs(range[0]).isValid() ? dayjs(range[0]).format('YYYY-MM-DD') : '' + const dateTo = + range[1] && dayjs(range[1]).isValid() ? dayjs(range[1]).format('YYYY-MM-DD') : '' + setPage(1) + setFilters({ + username: String(values.username || '').trim(), + dateFrom, + dateTo, + }) + } return (
- {query.error ? ( + {summaryQuery.error ? ( ) : null} - {query.isLoading ? ( + {summaryQuery.isLoading ? ( @@ -46,6 +170,83 @@ export default function AdminDashboardPage() { ))} )} + + + 全部后台账号的登录信息 + + + } + > + + layout="inline" + className="filter-form" + onFinish={applyLoginFilters} + initialValues={{ username: filters.username }} + style={{ marginBottom: 16 }} + > + + + + + + + + + + + + + + + {loginQuery.error ? ( + + ) : null} + + + rowKey="logId" + loading={loginQuery.isLoading || loginQuery.isFetching} + columns={columns} + dataSource={loginData?.items || []} + scroll={{ x: 1100 }} + pagination={buildAdminTablePagination({ + current: loginData?.pagination.page || page, + pageSize: loginData?.pagination.pageSize || pageSize, + total: loginData?.pagination.total || 0, + onChange: (nextPage, nextPageSize) => { + setPage(nextPage) + setPageSize(nextPageSize) + }, + })} + locale={{ emptyText: '暂无登录记录(新登录成功后会自动写入)' }} + /> +
) } diff --git a/apps/frontend/src/services/admin/dashboard.ts b/apps/frontend/src/services/admin/dashboard.ts index 518d3902..60dc4495 100644 --- a/apps/frontend/src/services/admin/dashboard.ts +++ b/apps/frontend/src/services/admin/dashboard.ts @@ -1,6 +1,25 @@ import { apiGet } from '@/lib/http' -import type { AdminDashboardSummary } from '@/types/admin' +import type { AdminDashboardSummary, AdminLoginLogListResult } from '@/types/admin' export function fetchAdminDashboardSummary() { return apiGet('/api/v1/admin/dashboard/summary') } + +export function fetchAdminLoginLogs(params: { + page?: number + pageSize?: number + username?: string + dateFrom?: string + dateTo?: string +} = {}) { + const search = new URLSearchParams() + if (params.page) search.set('page', String(params.page)) + if (params.pageSize) search.set('pageSize', String(params.pageSize)) + if (params.username) search.set('username', params.username) + if (params.dateFrom) search.set('dateFrom', params.dateFrom) + if (params.dateTo) search.set('dateTo', params.dateTo) + const queryString = search.toString() + return apiGet( + `/api/v1/admin/dashboard/login-logs${queryString ? `?${queryString}` : ''}`, + ) +} diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index 83428f38..079a2b4b 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -13,6 +13,9 @@ export type { AdminAuditLogItem } from './audit-logs' // Dashboard types export type { AdminDashboardSummary } from './dashboard' +// Login logs +export type { AdminLoginLogItem, AdminLoginLogListResult } from './login-logs' + // Orders types export type { AdminOrderFulfillmentProgress, diff --git a/apps/frontend/src/types/admin/login-logs.ts b/apps/frontend/src/types/admin/login-logs.ts new file mode 100644 index 00000000..132e41cf --- /dev/null +++ b/apps/frontend/src/types/admin/login-logs.ts @@ -0,0 +1,21 @@ +export interface AdminLoginLogItem { + logId: number + userId: number | null + username: string + role: string + ip: string + location: string + userAgent: string + success: boolean + failureReason: string + createdAt: string +} + +export interface AdminLoginLogListResult { + items: AdminLoginLogItem[] + pagination: { + page: number + pageSize: number + total: number + } +}