Merge pull request 'feat:添加管理员后台功能' (#17) from jianuo/whale-town-end:feat_2 into main
Reviewed-on: #17 Reviewed-by: moyin <2443444649@qq.com>
This commit was merged in pull request #17.
This commit is contained in:
14
.env.example
14
.env.example
@@ -15,6 +15,20 @@ NODE_ENV=development
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 管理员后台配置(开发环境推荐配置)
|
||||||
|
# ===========================================
|
||||||
|
# 管理员Token签名密钥(至少16字符,生产环境务必使用强随机值)
|
||||||
|
ADMIN_TOKEN_SECRET=dev_admin_token_secret_change_me_32chars
|
||||||
|
# 管理员Token有效期(秒),默认8小时
|
||||||
|
ADMIN_TOKEN_TTL_SECONDS=28800
|
||||||
|
|
||||||
|
# 启动引导创建管理员账号(仅当 enabled=true 时生效)
|
||||||
|
ADMIN_BOOTSTRAP_ENABLED=false
|
||||||
|
# ADMIN_USERNAME=admin
|
||||||
|
# ADMIN_PASSWORD=Admin123456
|
||||||
|
# ADMIN_NICKNAME=管理员
|
||||||
|
|
||||||
# JWT 配置
|
# JWT 配置
|
||||||
JWT_SECRET=test_jwt_secret_key_for_development_only_32chars
|
JWT_SECRET=test_jwt_secret_key_for_development_only_32chars
|
||||||
JWT_EXPIRES_IN=7d
|
JWT_EXPIRES_IN=7d
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ PORT=3000
|
|||||||
JWT_SECRET=your_jwt_secret_key_here_at_least_32_characters
|
JWT_SECRET=your_jwt_secret_key_here_at_least_32_characters
|
||||||
JWT_EXPIRES_IN=7d
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# 管理员后台配置(生产环境必须配置)
|
||||||
|
ADMIN_TOKEN_SECRET=please_use_a_strong_random_secret_at_least_32_chars
|
||||||
|
ADMIN_TOKEN_TTL_SECONDS=28800
|
||||||
|
|
||||||
|
# 启动引导创建管理员账号(建议仅首次部署临时开启,创建后关闭)
|
||||||
|
ADMIN_BOOTSTRAP_ENABLED=false
|
||||||
|
# ADMIN_USERNAME=admin
|
||||||
|
# ADMIN_PASSWORD=please_set_a_strong_password
|
||||||
|
# ADMIN_NICKNAME=管理员
|
||||||
|
|
||||||
# Redis 配置(用于验证码存储)
|
# Redis 配置(用于验证码存储)
|
||||||
# 生产环境使用真实Redis服务
|
# 生产环境使用真实Redis服务
|
||||||
USE_FILE_REDIS=false
|
USE_FILE_REDIS=false
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -46,6 +46,24 @@ pnpm run dev
|
|||||||
|
|
||||||
🎉 **服务启动成功!** 访问 http://localhost:3000
|
🎉 **服务启动成功!** 访问 http://localhost:3000
|
||||||
|
|
||||||
|
### 🧑💻 管理员后台(Ant Design)
|
||||||
|
|
||||||
|
项目包含一个最小可用的管理员后台(管理员登录 / 用户管理 / 重置密码),文档见:
|
||||||
|
|
||||||
|
- [docs/systems/admin-dashboard/README.md](docs/systems/admin-dashboard/README.md)
|
||||||
|
|
||||||
|
启动方式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# 后端
|
||||||
|
pnpm run dev
|
||||||
|
|
||||||
|
# 前端后台
|
||||||
|
pnpm -C client dev
|
||||||
|
```
|
||||||
|
|
||||||
### 🧪 快速测试
|
### 🧪 快速测试
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -179,6 +197,12 @@ pnpm run dev
|
|||||||
- **Swagger UI** `^5.0.1` - 交互式API文档界面
|
- **Swagger UI** `^5.0.1` - 交互式API文档界面
|
||||||
- **@nestjs/swagger** `^11.2.3` - NestJS Swagger集成
|
- **@nestjs/swagger** `^11.2.3` - NestJS Swagger集成
|
||||||
|
|
||||||
|
### 🧑💻 管理员后台(前端)
|
||||||
|
- **Vite** - 前端构建工具(本项目 admin dashboard 使用)
|
||||||
|
- **React** - 前端 UI 框架
|
||||||
|
- **React Router** - 前端路由
|
||||||
|
- **Ant Design** - 企业级 UI 组件库
|
||||||
|
|
||||||
### 📊 日志监控
|
### 📊 日志监控
|
||||||
- **Pino** `^10.1.0` - 高性能结构化日志库
|
- **Pino** `^10.1.0` - 高性能结构化日志库
|
||||||
- **nestjs-pino** `^4.5.0` - NestJS Pino集成
|
- **nestjs-pino** `^4.5.0` - NestJS Pino集成
|
||||||
|
|||||||
4
client/.env.example
Normal file
4
client/.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# 前端后台配置
|
||||||
|
# 复制为 .env.local
|
||||||
|
|
||||||
|
VITE_API_BASE_URL=http://localhost:3000
|
||||||
12
client/index.html
Normal file
12
client/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Whale Town Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
client/package.json
Normal file
24
client/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "whale-town-admin",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"antd": "^5.27.3",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.30.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.24",
|
||||||
|
"@types/react-dom": "^18.3.7",
|
||||||
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
61
client/src/app/AdminLayout.tsx
Normal file
61
client/src/app/AdminLayout.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Layout, Menu, Typography } from 'antd';
|
||||||
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { clearAuth } from '../lib/adminAuth';
|
||||||
|
|
||||||
|
const { Header, Content, Sider } = Layout;
|
||||||
|
|
||||||
|
export function AdminLayout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const selectedKey = location.pathname.startsWith('/logs')
|
||||||
|
? 'logs'
|
||||||
|
: location.pathname.startsWith('/users')
|
||||||
|
? 'users'
|
||||||
|
: 'users';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
|
<Sider width={220}>
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<Typography.Title level={5} style={{ color: 'white', margin: 0 }}>
|
||||||
|
Whale Town Admin
|
||||||
|
</Typography.Title>
|
||||||
|
</div>
|
||||||
|
<Menu
|
||||||
|
theme="dark"
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[selectedKey]}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'users',
|
||||||
|
label: '用户管理',
|
||||||
|
onClick: () => navigate('/users'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logs',
|
||||||
|
label: '运行日志',
|
||||||
|
onClick: () => navigate('/logs'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logout',
|
||||||
|
label: '退出登录',
|
||||||
|
onClick: () => {
|
||||||
|
clearAuth();
|
||||||
|
navigate('/login');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
<Layout>
|
||||||
|
<Header style={{ background: 'white', display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Typography.Text>后台管理</Typography.Text>
|
||||||
|
</Header>
|
||||||
|
<Content style={{ padding: 16 }}>
|
||||||
|
<Outlet />
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
client/src/app/App.tsx
Normal file
28
client/src/app/App.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { ConfigProvider } from 'antd';
|
||||||
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
import { AdminLayout } from './AdminLayout';
|
||||||
|
import { LoginPage } from '../pages/LoginPage';
|
||||||
|
import { UsersPage } from '../pages/UsersPage';
|
||||||
|
import { LogsPage } from '../pages/LogsPage';
|
||||||
|
import { isAuthed } from '../lib/adminAuth';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<ConfigProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={isAuthed() ? <AdminLayout /> : <Navigate to="/login" replace />}
|
||||||
|
>
|
||||||
|
<Route index element={<Navigate to="/users" replace />} />
|
||||||
|
<Route path="users" element={<UsersPage />} />
|
||||||
|
<Route path="logs" element={<LogsPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to={isAuthed() ? '/users' : '/login'} replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
client/src/lib/adminAuth.ts
Normal file
17
client/src/lib/adminAuth.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const TOKEN_KEY = 'whale_town_admin_token';
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string): void {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuth(): void {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthed(): boolean {
|
||||||
|
return Boolean(getToken());
|
||||||
|
}
|
||||||
130
client/src/lib/api.ts
Normal file
130
client/src/lib/api.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { getToken, clearAuth } from './adminAuth';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFilenameFromContentDisposition(contentDisposition: string | null): string | null {
|
||||||
|
if (!contentDisposition) return null;
|
||||||
|
|
||||||
|
// Prefer RFC 5987 filename*=UTF-8''...
|
||||||
|
const filenameStarMatch = contentDisposition.match(/filename\*=(?:UTF-8''|utf-8'')([^;]+)/);
|
||||||
|
if (filenameStarMatch?.[1]) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(filenameStarMatch[1].trim().replace(/^"|"$/g, ''));
|
||||||
|
} catch {
|
||||||
|
return filenameStarMatch[1].trim().replace(/^"|"$/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filenameMatch = contentDisposition.match(/filename=([^;]+)/);
|
||||||
|
if (filenameMatch?.[1]) {
|
||||||
|
return filenameMatch[1].trim().replace(/^"|"$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const token = getToken();
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
...(init?.headers || {}),
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
clearAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json().catch(() => ({}))) as any;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(data?.message || `请求失败: ${res.status}`, res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestDownload(path: string, init?: RequestInit): Promise<{ blob: Blob; filename: string }>
|
||||||
|
{
|
||||||
|
const token = getToken();
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...(init?.headers as any),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Do NOT force Content-Type for downloads (GET binary)
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
clearAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
// Try to extract message from JSON-ish body
|
||||||
|
let message = `请求失败: ${res.status}`;
|
||||||
|
try {
|
||||||
|
const maybeJson = JSON.parse(text || '{}');
|
||||||
|
message = maybeJson?.message || message;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
throw new ApiError(message, res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename =
|
||||||
|
parseFilenameFromContentDisposition(res.headers.get('content-disposition')) || 'logs.tar.gz';
|
||||||
|
const blob = await res.blob();
|
||||||
|
return { blob, filename };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
adminLogin: (identifier: string, password: string) =>
|
||||||
|
request<any>('/admin/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ identifier, password }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
listUsers: (limit = 100, offset = 0) =>
|
||||||
|
request<any>(`/admin/users?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`),
|
||||||
|
|
||||||
|
resetUserPassword: (userId: string, newPassword: string) =>
|
||||||
|
request<any>(`/admin/users/${encodeURIComponent(userId)}/reset-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ new_password: newPassword }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getRuntimeLogs: (lines = 200) =>
|
||||||
|
request<any>(`/admin/logs/runtime?lines=${encodeURIComponent(lines)}`),
|
||||||
|
|
||||||
|
downloadLogsArchive: () => requestDownload('/admin/logs/archive'),
|
||||||
|
};
|
||||||
9
client/src/main.tsx
Normal file
9
client/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { App } from './app/App';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
50
client/src/pages/LoginPage.tsx
Normal file
50
client/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Button, Card, Form, Input, Typography, message } from 'antd';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { setToken } from '../lib/adminAuth';
|
||||||
|
|
||||||
|
type LoginValues = {
|
||||||
|
identifier: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [form] = Form.useForm<LoginValues>();
|
||||||
|
|
||||||
|
const onFinish = async (values: LoginValues) => {
|
||||||
|
try {
|
||||||
|
const res = await api.adminLogin(values.identifier, values.password);
|
||||||
|
if (!res?.success || !res?.data?.access_token) {
|
||||||
|
throw new Error(res?.message || '登录失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
setToken(res.data.access_token);
|
||||||
|
message.success('登录成功');
|
||||||
|
navigate('/users');
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '登录失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||||
|
<Card style={{ width: 420 }}>
|
||||||
|
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||||
|
管理员登录
|
||||||
|
</Typography.Title>
|
||||||
|
<Form form={form} layout="vertical" onFinish={onFinish}>
|
||||||
|
<Form.Item name="identifier" label="用户名/邮箱/手机号" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="admin" autoComplete="username" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="password" label="密码" rules={[{ required: true }]}>
|
||||||
|
<Input.Password placeholder="请输入密码" autoComplete="current-password" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" block>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
client/src/pages/LogsPage.tsx
Normal file
106
client/src/pages/LogsPage.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Alert, Button, Card, InputNumber, Space, Typography } from 'antd';
|
||||||
|
import { api, ApiError } from '../lib/api';
|
||||||
|
|
||||||
|
export function LogsPage() {
|
||||||
|
const [lines, setLines] = useState<number>(200);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [downloadLoading, setDownloadLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [file, setFile] = useState<string>('');
|
||||||
|
const [updatedAt, setUpdatedAt] = useState<string>('');
|
||||||
|
const [logLines, setLogLines] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const logText = useMemo(() => logLines.join('\n'), [logLines]);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await api.getRuntimeLogs(lines);
|
||||||
|
if (!res?.success) {
|
||||||
|
setError(res?.message || '运行日志获取失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFile(res?.data?.file || '');
|
||||||
|
setUpdatedAt(res?.data?.updated_at || '');
|
||||||
|
setLogLines(Array.isArray(res?.data?.lines) ? res.data.lines : []);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) {
|
||||||
|
setError(e.message);
|
||||||
|
} else {
|
||||||
|
setError(e instanceof Error ? e.message : '运行日志获取失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const downloadArchive = async () => {
|
||||||
|
setDownloadLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { blob, filename } = await api.downloadLogsArchive();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename || 'logs.tar.gz';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) {
|
||||||
|
setError(e.message);
|
||||||
|
} else {
|
||||||
|
setError(e instanceof Error ? e.message : '日志下载失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDownloadLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
{error ? <Alert type="error" message={error} /> : null}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="运行日志"
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<span>行数</span>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={2000}
|
||||||
|
value={lines}
|
||||||
|
onChange={(v) => setLines(typeof v === 'number' ? v : 200)}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => void load()} loading={loading}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void downloadArchive()} loading={downloadLoading}>
|
||||||
|
下载日志压缩包
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{file ? `文件:${file}` : '文件:-'}
|
||||||
|
{updatedAt ? ` 更新时间:${updatedAt}` : ''}
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
|
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{logText || '暂无日志'}</pre>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
client/src/pages/UsersPage.tsx
Normal file
161
client/src/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { Button, Card, Form, Input, Modal, Space, Table, Typography, message } from 'antd';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
|
||||||
|
type UserRow = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
email?: string;
|
||||||
|
email_verified: boolean;
|
||||||
|
phone?: string;
|
||||||
|
role: number;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResetValues = {
|
||||||
|
newPassword: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UsersPage() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [rows, setRows] = useState<UserRow[]>([]);
|
||||||
|
const [resetOpen, setResetOpen] = useState(false);
|
||||||
|
const [resetUserId, setResetUserId] = useState<string | null>(null);
|
||||||
|
const [resetForm] = Form.useForm<ResetValues>();
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{ title: 'ID', dataIndex: 'id', key: 'id', width: 90 },
|
||||||
|
{ title: '用户名', dataIndex: 'username', key: 'username' },
|
||||||
|
{ title: '昵称', dataIndex: 'nickname', key: 'nickname' },
|
||||||
|
{ title: '邮箱', dataIndex: 'email', key: 'email' },
|
||||||
|
{
|
||||||
|
title: '邮箱验证',
|
||||||
|
dataIndex: 'email_verified',
|
||||||
|
key: 'email_verified',
|
||||||
|
render: (v: boolean) => (v ? '已验证' : '未验证'),
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{ title: '手机号', dataIndex: 'phone', key: 'phone' },
|
||||||
|
{ title: '角色', dataIndex: 'role', key: 'role', width: 80 },
|
||||||
|
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 160,
|
||||||
|
render: (_: any, row: UserRow) => (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setResetUserId(row.id);
|
||||||
|
resetForm.resetFields();
|
||||||
|
setResetOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置密码
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[resetForm],
|
||||||
|
);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.listUsers(200, 0);
|
||||||
|
const users = res?.data?.users || [];
|
||||||
|
setRows(
|
||||||
|
users.map((u: any) => ({
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
nickname: u.nickname,
|
||||||
|
email: u.email || undefined,
|
||||||
|
email_verified: Boolean(u.email_verified),
|
||||||
|
phone: u.phone || undefined,
|
||||||
|
role: u.role,
|
||||||
|
created_at: u.created_at,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onResetOk = async () => {
|
||||||
|
try {
|
||||||
|
const values = await resetForm.validateFields();
|
||||||
|
if (!resetUserId) return;
|
||||||
|
|
||||||
|
await api.resetUserPassword(resetUserId, values.newPassword);
|
||||||
|
message.success('密码已重置');
|
||||||
|
setResetOpen(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.errorFields) return;
|
||||||
|
message.error(e?.message || '重置失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||||
|
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
用户管理
|
||||||
|
</Typography.Title>
|
||||||
|
<Button onClick={load} loading={loading}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns as any}
|
||||||
|
dataSource={rows}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`重置密码${resetUserId ? `(用户ID: ${resetUserId})` : ''}`}
|
||||||
|
open={resetOpen}
|
||||||
|
onOk={onResetOk}
|
||||||
|
onCancel={() => setResetOpen(false)}
|
||||||
|
okText="确认"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Form form={resetForm} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
label="新密码"
|
||||||
|
name="newPassword"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入新密码' },
|
||||||
|
{ min: 8, message: '至少8位' },
|
||||||
|
{
|
||||||
|
validator: (_, v) => {
|
||||||
|
const hasLetter = /[a-zA-Z]/.test(v || '');
|
||||||
|
const hasNumber = /\d/.test(v || '');
|
||||||
|
if (!v) return Promise.resolve();
|
||||||
|
if (!hasLetter || !hasNumber) return Promise.reject(new Error('必须包含字母和数字'));
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="例如 NewPass1234" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
client/tsconfig.json
Normal file
19
client/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
9
client/vite.config.ts
Normal file
9
client/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -409,4 +409,50 @@ curl -X POST http://localhost:3000/auth/reset-password \
|
|||||||
|
|
||||||
## 更新日志
|
## 更新日志
|
||||||
|
|
||||||
- **v1.0.0** (2025-12-17): 初始版本,包含基础的用户认证功能
|
- **v1.0.0** (2025-12-17): 初始版本,包含基础的用户认证功能
|
||||||
|
## 管理员接口
|
||||||
|
|
||||||
|
**注意**:所有管理员接口都需要在 Header 中携带 ,且用户角色必须为管理员 (role=9)。
|
||||||
|
|
||||||
|
### 1. 获取用户列表
|
||||||
|
|
||||||
|
**接口地址**: `GET /admin/users`
|
||||||
|
|
||||||
|
**功能描述**: 分页获取所有注册用户列表
|
||||||
|
|
||||||
|
#### 请求参数
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| page | number | 否 | 页码,默认1 |
|
||||||
|
| limit | number | 否 | 每页数量,默认10 |
|
||||||
|
|
||||||
|
### 2. 重置用户密码
|
||||||
|
|
||||||
|
**接口地址**: `POST /admin/users/:id/reset-password`
|
||||||
|
|
||||||
|
**功能描述**: 管理员强制重置指定用户的密码
|
||||||
|
|
||||||
|
#### 请求参数
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| password | string | 是 | 新密码 |
|
||||||
|
|
||||||
|
### 3. 删除用户
|
||||||
|
|
||||||
|
**接口地址**: `DELETE /admin/users/:id`
|
||||||
|
|
||||||
|
**功能描述**: 删除指定用户
|
||||||
|
|
||||||
|
### 4. 修改用户角色
|
||||||
|
|
||||||
|
**接口地址**: `POST /admin/users/:id/role`
|
||||||
|
|
||||||
|
**功能描述**: 修改用户的角色权限
|
||||||
|
|
||||||
|
#### 请求参数
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| role | number | 是 | 角色ID (1:普通用户, 9:管理员) |
|
||||||
|
|||||||
186
docs/systems/admin-dashboard/README.md
Normal file
186
docs/systems/admin-dashboard/README.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# 管理员后台(Admin Dashboard)
|
||||||
|
|
||||||
|
本模块提供 Whale Town 的管理员后台能力,包含:
|
||||||
|
|
||||||
|
- 管理员登录(role=9)
|
||||||
|
- 用户列表管理
|
||||||
|
- 用户密码重置
|
||||||
|
- 运行日志查看(读取 logs/ 下最新日志)
|
||||||
|
|
||||||
|
> 说明:本项目用户系统原本的 `access_token` 为演示用 Base64 令牌。为了不影响现有用户端流程,管理员后台使用单独的签名 Token(HMAC-SHA256)做鉴权。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 管理员账号设计
|
||||||
|
|
||||||
|
### 1.1 角色约定
|
||||||
|
|
||||||
|
用户表 `users.role`:
|
||||||
|
|
||||||
|
- `1`:普通用户
|
||||||
|
- `9`:管理员(可访问后台)
|
||||||
|
|
||||||
|
### 1.2 启动引导创建管理员(可选)
|
||||||
|
|
||||||
|
通过环境变量启用启动引导:在服务启动时,如果不存在指定用户名的用户,则自动创建一个管理员账户(role=9)。
|
||||||
|
|
||||||
|
在 `.env` 中配置:
|
||||||
|
|
||||||
|
- `ADMIN_BOOTSTRAP_ENABLED=true`
|
||||||
|
- `ADMIN_USERNAME=admin`
|
||||||
|
- `ADMIN_PASSWORD=Admin123456`(需满足密码强度:至少8位,包含字母和数字)
|
||||||
|
- `ADMIN_NICKNAME=管理员`(可选)
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- 建议仅在首次部署/开发环境开启,引导创建成功后可关闭。
|
||||||
|
- 生产环境务必设置强随机密码与强随机 `ADMIN_TOKEN_SECRET`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 管理员鉴权 Token
|
||||||
|
|
||||||
|
### 2.1 配置项
|
||||||
|
|
||||||
|
- `ADMIN_TOKEN_SECRET`:签名密钥(至少16字符;生产环境建议≥32并随机)
|
||||||
|
- `ADMIN_TOKEN_TTL_SECONDS`:Token 有效期(秒),默认 `28800`(8小时)
|
||||||
|
|
||||||
|
### 2.2 使用方式
|
||||||
|
|
||||||
|
管理员登录成功后返回 `access_token`,后续请求在 Header 中携带:
|
||||||
|
|
||||||
|
- `Authorization: Bearer <access_token>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 后端接口
|
||||||
|
|
||||||
|
### 3.1 管理员登录
|
||||||
|
|
||||||
|
- `POST /admin/auth/login`
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"identifier": "admin",
|
||||||
|
"password": "Admin123456"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应(成功):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"admin": { "id": "1", "username": "admin", "nickname": "管理员", "role": 9 },
|
||||||
|
"access_token": "...",
|
||||||
|
"expires_at": 1766102400000
|
||||||
|
},
|
||||||
|
"message": "管理员登录成功"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 用户列表
|
||||||
|
|
||||||
|
- `GET /admin/users?limit=100&offset=0`
|
||||||
|
- 需要管理员 Token
|
||||||
|
|
||||||
|
### 3.3 用户详情
|
||||||
|
|
||||||
|
- `GET /admin/users/:id`
|
||||||
|
- 需要管理员 Token
|
||||||
|
|
||||||
|
### 3.4 重置用户密码
|
||||||
|
|
||||||
|
- `POST /admin/users/:id/reset-password`
|
||||||
|
- 需要管理员 Token
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"new_password": "NewPass1234"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 运行日志(tail)
|
||||||
|
|
||||||
|
- `GET /admin/logs/runtime?lines=200`
|
||||||
|
- 需要管理员 Token
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 开发环境默认读取 `logs/dev.log`
|
||||||
|
- 生产环境默认读取 `logs/app.log`
|
||||||
|
- `lines` 默认 200,最大 2000
|
||||||
|
|
||||||
|
### 3.6 下载全部运行日志(archive)
|
||||||
|
|
||||||
|
- `GET /admin/logs/archive`
|
||||||
|
- 需要管理员 Token
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 返回一个 `tar.gz` 文件(浏览器会触发下载)
|
||||||
|
- 内容为整个 `logs/` 目录(例如开发环境的 `dev.log`,生产环境的 `app.log/access.log/error.log` 等)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 前端后台(Ant Design)
|
||||||
|
|
||||||
|
前端工程位于 `client/`,使用 Vite + React + Ant Design。
|
||||||
|
|
||||||
|
### 4.1 安装依赖
|
||||||
|
|
||||||
|
在项目根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 启动后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 启动前端后台
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm -C client dev
|
||||||
|
```
|
||||||
|
|
||||||
|
默认访问:
|
||||||
|
|
||||||
|
- 前端:`http://localhost:5173`
|
||||||
|
- 后端:`http://localhost:3000`
|
||||||
|
- Swagger:`http://localhost:3000/api-docs`
|
||||||
|
|
||||||
|
页面说明:
|
||||||
|
|
||||||
|
- 用户管理:`/users`
|
||||||
|
- 运行日志:`/logs`
|
||||||
|
|
||||||
|
在“运行日志”页面可点击“下载日志压缩包”获取整个 `logs/` 目录的打包文件。
|
||||||
|
|
||||||
|
### 4.4 前端配置
|
||||||
|
|
||||||
|
- 复制 `client/.env.example` 为 `client/.env.local`
|
||||||
|
- 可通过 `VITE_API_BASE_URL` 指向后端地址
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 代码位置
|
||||||
|
|
||||||
|
- 后端:
|
||||||
|
- `src/core/admin_core/`:管理员核心逻辑
|
||||||
|
- `src/core/guards/admin.guard.ts`:管理员接口鉴权
|
||||||
|
- `src/business/admin/`:管理员HTTP API
|
||||||
|
- `src/dto/admin*.ts`:管理员请求/响应 DTO
|
||||||
|
|
||||||
|
- 前端:
|
||||||
|
- `client/src/pages/LoginPage.tsx`:管理员登录页
|
||||||
|
- `client/src/pages/UsersPage.tsx`:用户管理页(列表+重置密码)
|
||||||
|
- `client/src/pages/LogsPage.tsx`:运行日志页
|
||||||
@@ -31,7 +31,9 @@
|
|||||||
"@nestjs/swagger": "^11.2.3",
|
"@nestjs/swagger": "^11.2.3",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
"@nestjs/websockets": "^10.4.20",
|
"@nestjs/websockets": "^10.4.20",
|
||||||
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
packages:
|
||||||
|
- 'client'
|
||||||
|
|
||||||
ignoredBuiltDependencies:
|
ignoredBuiltDependencies:
|
||||||
- '@nestjs/core'
|
- '@nestjs/core'
|
||||||
- '@scarf/scarf'
|
- '@scarf/scarf'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { UsersModule } from './core/db/users/users.module';
|
|||||||
import { LoginCoreModule } from './core/login_core/login_core.module';
|
import { LoginCoreModule } from './core/login_core/login_core.module';
|
||||||
import { LoginModule } from './business/login/login.module';
|
import { LoginModule } from './business/login/login.module';
|
||||||
import { RedisModule } from './core/redis/redis.module';
|
import { RedisModule } from './core/redis/redis.module';
|
||||||
|
import { AdminModule } from './business/admin/admin.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查数据库配置是否完整 by angjustinl 2025-12-17
|
* 检查数据库配置是否完整 by angjustinl 2025-12-17
|
||||||
@@ -61,6 +62,7 @@ function isDatabaseConfigured(): boolean {
|
|||||||
isDatabaseConfigured() ? UsersModule.forDatabase() : UsersModule.forMemory(),
|
isDatabaseConfigured() ? UsersModule.forDatabase() : UsersModule.forMemory(),
|
||||||
LoginCoreModule,
|
LoginCoreModule,
|
||||||
LoginModule,
|
LoginModule,
|
||||||
|
AdminModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
171
src/business/admin/admin.controller.ts
Normal file
171
src/business/admin/admin.controller.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* 管理员控制器
|
||||||
|
*
|
||||||
|
* API端点:
|
||||||
|
* - POST /admin/auth/login 管理员登录
|
||||||
|
* - GET /admin/users 用户列表(需要管理员Token)
|
||||||
|
* - GET /admin/users/:id 用户详情(需要管理员Token)
|
||||||
|
* - POST /admin/users/:id/reset-password 重置指定用户密码(需要管理员Token)
|
||||||
|
* - GET /admin/logs/runtime 获取运行日志尾部(需要管理员Token)
|
||||||
|
*
|
||||||
|
* @author jianuo
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-19
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, UseGuards, ValidationPipe, UsePipes, Res, Logger } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiProduces, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { AdminGuard } from '../../core/guards/admin.guard';
|
||||||
|
import { AdminService } from './admin.service';
|
||||||
|
import { AdminLoginDto, AdminResetPasswordDto } from '../../dto/admin.dto';
|
||||||
|
import { AdminLoginResponseDto, AdminUsersResponseDto, AdminCommonResponseDto, AdminUserResponseDto, AdminRuntimeLogsResponseDto } from '../../dto/admin_response.dto';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { pipeline } from 'stream';
|
||||||
|
|
||||||
|
@ApiTags('admin')
|
||||||
|
@Controller('admin')
|
||||||
|
export class AdminController {
|
||||||
|
private readonly logger = new Logger(AdminController.name);
|
||||||
|
|
||||||
|
constructor(private readonly adminService: AdminService) {}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: '管理员登录', description: '仅允许 role=9 的账户登录后台' })
|
||||||
|
@ApiBody({ type: AdminLoginDto })
|
||||||
|
@ApiResponse({ status: 200, description: '登录成功', type: AdminLoginResponseDto })
|
||||||
|
@Post('auth/login')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
async login(@Body() dto: AdminLoginDto) {
|
||||||
|
return await this.adminService.login(dto.identifier, dto.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: '获取用户列表', description: '后台用户管理:分页获取用户列表' })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, description: '返回数量(默认100)' })
|
||||||
|
@ApiQuery({ name: 'offset', required: false, description: '偏移量(默认0)' })
|
||||||
|
@ApiResponse({ status: 200, description: '获取成功', type: AdminUsersResponseDto })
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
@Get('users')
|
||||||
|
async listUsers(
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
@Query('offset') offset?: string,
|
||||||
|
) {
|
||||||
|
const parsedLimit = limit ? Number(limit) : 100;
|
||||||
|
const parsedOffset = offset ? Number(offset) : 0;
|
||||||
|
return await this.adminService.listUsers(parsedLimit, parsedOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: '获取用户详情' })
|
||||||
|
@ApiParam({ name: 'id', description: '用户ID' })
|
||||||
|
@ApiResponse({ status: 200, description: '获取成功', type: AdminUserResponseDto })
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
@Get('users/:id')
|
||||||
|
async getUser(@Param('id') id: string) {
|
||||||
|
return await this.adminService.getUser(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: '重置用户密码', description: '管理员直接为用户设置新密码(需满足密码强度规则)' })
|
||||||
|
@ApiParam({ name: 'id', description: '用户ID' })
|
||||||
|
@ApiBody({ type: AdminResetPasswordDto })
|
||||||
|
@ApiResponse({ status: 200, description: '重置成功', type: AdminCommonResponseDto })
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
@Post('users/:id/reset-password')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true }))
|
||||||
|
async resetPassword(@Param('id') id: string, @Body() dto: AdminResetPasswordDto) {
|
||||||
|
return await this.adminService.resetPassword(BigInt(id), dto.new_password);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: '获取运行日志尾部', description: '从 logs/ 目录读取最近的日志行(默认200行)' })
|
||||||
|
@ApiQuery({ name: 'lines', required: false, description: '返回行数(默认200,最大2000)' })
|
||||||
|
@ApiResponse({ status: 200, description: '获取成功', type: AdminRuntimeLogsResponseDto })
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
@Get('logs/runtime')
|
||||||
|
async getRuntimeLogs(@Query('lines') lines?: string) {
|
||||||
|
const parsedLines = lines ? Number(lines) : undefined;
|
||||||
|
return await this.adminService.getRuntimeLogs(parsedLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: '下载全部运行日志', description: '将 logs/ 目录打包为 tar.gz 并下载(需要管理员Token)' })
|
||||||
|
@ApiProduces('application/gzip')
|
||||||
|
@ApiResponse({ status: 200, description: '打包下载成功(tar.gz 二进制流)' })
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
@Get('logs/archive')
|
||||||
|
async downloadLogsArchive(@Res() res: Response) {
|
||||||
|
const logDir = this.adminService.getLogDirAbsolutePath();
|
||||||
|
|
||||||
|
if (!fs.existsSync(logDir)) {
|
||||||
|
res.status(404).json({ success: false, message: '日志目录不存在' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = fs.statSync(logDir);
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
res.status(404).json({ success: false, message: '日志目录不可用' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentDir = path.dirname(logDir);
|
||||||
|
const baseName = path.basename(logDir);
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const filename = `logs-${ts}.tar.gz`;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/gzip');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
|
||||||
|
const tar = spawn('tar', ['-czf', '-', '-C', parentDir, baseName], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
tar.stderr.on('data', (chunk: Buffer) => {
|
||||||
|
const msg = chunk.toString('utf8').trim();
|
||||||
|
if (msg) {
|
||||||
|
this.logger.warn(`tar stderr: ${msg}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tar.on('error', (err: any) => {
|
||||||
|
this.logger.error('打包日志失败(tar 进程启动失败)', err?.stack || String(err));
|
||||||
|
if (!res.headersSent) {
|
||||||
|
const msg = err?.code === 'ENOENT' ? '服务器缺少 tar 命令,无法打包日志' : '日志打包失败';
|
||||||
|
res.status(500).json({ success: false, message: msg });
|
||||||
|
} else {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pipelinePromise = new Promise<void>((resolve, reject) => {
|
||||||
|
pipeline(tar.stdout, res, (err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
const exitPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
tar.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`tar exited with code ${code ?? 'unknown'}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pipelinePromise;
|
||||||
|
await exitPromise;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('打包日志失败(tar 执行或输出失败)', err instanceof Error ? err.stack : String(err));
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ success: false, message: '日志打包失败' });
|
||||||
|
} else {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/business/admin/admin.module.ts
Normal file
25
src/business/admin/admin.module.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* 管理员业务模块
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 提供后台管理的HTTP API(管理员登录、用户管理、密码重置等)
|
||||||
|
* - 仅负责HTTP层与业务流程编排
|
||||||
|
* - 核心鉴权与密码策略由 AdminCoreService 提供
|
||||||
|
*
|
||||||
|
* @author jianuo
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-19
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AdminCoreModule } from '../../core/admin_core/admin_core.module';
|
||||||
|
import { LoggerModule } from '../../core/utils/logger/logger.module';
|
||||||
|
import { AdminController } from './admin.controller';
|
||||||
|
import { AdminService } from './admin.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AdminCoreModule, LoggerModule],
|
||||||
|
controllers: [AdminController],
|
||||||
|
providers: [AdminService],
|
||||||
|
})
|
||||||
|
export class AdminModule {}
|
||||||
159
src/business/admin/admin.service.spec.ts
Normal file
159
src/business/admin/admin.service.spec.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import { AdminService } from './admin.service';
|
||||||
|
import { AdminCoreService } from '../../core/admin_core/admin_core.service';
|
||||||
|
import { LogManagementService } from '../../core/utils/logger/log_management.service';
|
||||||
|
import { Users } from '../../core/db/users/users.entity';
|
||||||
|
|
||||||
|
describe('AdminService', () => {
|
||||||
|
let service: AdminService;
|
||||||
|
|
||||||
|
const adminCoreServiceMock: Pick<AdminCoreService, 'login' | 'resetUserPassword'> = {
|
||||||
|
login: jest.fn(),
|
||||||
|
resetUserPassword: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const usersServiceMock = {
|
||||||
|
findAll: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const logManagementServiceMock: Pick<LogManagementService, 'getRuntimeLogTail' | 'getLogDirAbsolutePath'> = {
|
||||||
|
getRuntimeLogTail: jest.fn(),
|
||||||
|
getLogDirAbsolutePath: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
service = new AdminService(
|
||||||
|
adminCoreServiceMock as unknown as AdminCoreService,
|
||||||
|
usersServiceMock as any,
|
||||||
|
logManagementServiceMock as unknown as LogManagementService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should login admin successfully', async () => {
|
||||||
|
(adminCoreServiceMock.login as jest.Mock).mockResolvedValue({
|
||||||
|
admin: { id: '1', username: 'admin', nickname: '管理员', role: 9 },
|
||||||
|
access_token: 'token',
|
||||||
|
expires_at: 123,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await service.login('admin', 'Admin123456');
|
||||||
|
|
||||||
|
expect(adminCoreServiceMock.login).toHaveBeenCalledWith({ identifier: 'admin', password: 'Admin123456' });
|
||||||
|
expect(res.success).toBe(true);
|
||||||
|
expect(res.data?.admin?.role).toBe(9);
|
||||||
|
expect(res.message).toBe('管理员登录成功');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle login failure', async () => {
|
||||||
|
(adminCoreServiceMock.login as jest.Mock).mockRejectedValue(new Error('密码错误'));
|
||||||
|
|
||||||
|
const res = await service.login('admin', 'bad');
|
||||||
|
|
||||||
|
expect(res.success).toBe(false);
|
||||||
|
expect(res.error_code).toBe('ADMIN_LOGIN_FAILED');
|
||||||
|
expect(res.message).toBe('密码错误');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-Error login failure', async () => {
|
||||||
|
(adminCoreServiceMock.login as jest.Mock).mockRejectedValue('boom');
|
||||||
|
|
||||||
|
const res = await service.login('admin', 'bad');
|
||||||
|
|
||||||
|
expect(res.success).toBe(false);
|
||||||
|
expect(res.error_code).toBe('ADMIN_LOGIN_FAILED');
|
||||||
|
expect(res.message).toBe('管理员登录失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list users with pagination', async () => {
|
||||||
|
const user = {
|
||||||
|
id: BigInt(1),
|
||||||
|
username: 'u1',
|
||||||
|
nickname: 'U1',
|
||||||
|
email: 'u1@test.com',
|
||||||
|
email_verified: true,
|
||||||
|
phone: null,
|
||||||
|
avatar_url: null,
|
||||||
|
role: 1,
|
||||||
|
created_at: new Date('2025-01-01T00:00:00Z'),
|
||||||
|
updated_at: new Date('2025-01-02T00:00:00Z'),
|
||||||
|
} as unknown as Users;
|
||||||
|
|
||||||
|
usersServiceMock.findAll.mockResolvedValue([user]);
|
||||||
|
|
||||||
|
const res = await service.listUsers(100, 0);
|
||||||
|
|
||||||
|
expect(usersServiceMock.findAll).toHaveBeenCalledWith(100, 0);
|
||||||
|
expect(res.success).toBe(true);
|
||||||
|
expect(res.data?.users).toHaveLength(1);
|
||||||
|
expect(res.data?.users[0]).toMatchObject({
|
||||||
|
id: '1',
|
||||||
|
username: 'u1',
|
||||||
|
nickname: 'U1',
|
||||||
|
role: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get user by id', async () => {
|
||||||
|
const user = {
|
||||||
|
id: BigInt(3),
|
||||||
|
username: 'u3',
|
||||||
|
nickname: 'U3',
|
||||||
|
email: null,
|
||||||
|
email_verified: false,
|
||||||
|
phone: '123',
|
||||||
|
avatar_url: null,
|
||||||
|
role: 1,
|
||||||
|
created_at: new Date('2025-01-01T00:00:00Z'),
|
||||||
|
updated_at: new Date('2025-01-02T00:00:00Z'),
|
||||||
|
} as unknown as Users;
|
||||||
|
|
||||||
|
usersServiceMock.findOne.mockResolvedValue(user);
|
||||||
|
|
||||||
|
const res = await service.getUser(BigInt(3));
|
||||||
|
|
||||||
|
expect(usersServiceMock.findOne).toHaveBeenCalledWith(BigInt(3));
|
||||||
|
expect(res.success).toBe(true);
|
||||||
|
expect(res.data?.user).toMatchObject({ id: '3', username: 'u3', nickname: 'U3' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset user password', async () => {
|
||||||
|
usersServiceMock.findOne.mockResolvedValue({ id: BigInt(2) } as unknown as Users);
|
||||||
|
(adminCoreServiceMock.resetUserPassword as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const res = await service.resetPassword(BigInt(2), 'NewPass1234');
|
||||||
|
|
||||||
|
expect(usersServiceMock.findOne).toHaveBeenCalledWith(BigInt(2));
|
||||||
|
expect(adminCoreServiceMock.resetUserPassword).toHaveBeenCalledWith(BigInt(2), 'NewPass1234');
|
||||||
|
expect(res).toEqual({ success: true, message: '密码重置成功' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException when resetting password for missing user', async () => {
|
||||||
|
usersServiceMock.findOne.mockRejectedValue(new Error('not found'));
|
||||||
|
|
||||||
|
await expect(service.resetPassword(BigInt(999), 'NewPass1234')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get runtime logs', async () => {
|
||||||
|
(logManagementServiceMock.getRuntimeLogTail as jest.Mock).mockResolvedValue({
|
||||||
|
file: 'dev.log',
|
||||||
|
updated_at: '2025-01-01T00:00:00.000Z',
|
||||||
|
lines: ['a', 'b'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await service.getRuntimeLogs(2);
|
||||||
|
|
||||||
|
expect(logManagementServiceMock.getRuntimeLogTail).toHaveBeenCalledWith({ lines: 2 });
|
||||||
|
expect(res.success).toBe(true);
|
||||||
|
expect(res.data?.file).toBe('dev.log');
|
||||||
|
expect(res.data?.lines).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should expose log dir absolute path', () => {
|
||||||
|
(logManagementServiceMock.getLogDirAbsolutePath as jest.Mock).mockReturnValue('/abs/logs');
|
||||||
|
|
||||||
|
expect(service.getLogDirAbsolutePath()).toBe('/abs/logs');
|
||||||
|
expect(logManagementServiceMock.getLogDirAbsolutePath).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
115
src/business/admin/admin.service.ts
Normal file
115
src/business/admin/admin.service.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* 管理员业务服务
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 调用核心服务完成管理员登录
|
||||||
|
* - 提供用户列表查询
|
||||||
|
* - 提供用户密码重置能力
|
||||||
|
*
|
||||||
|
* @author jianuo
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-19
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { AdminCoreService } from '../../core/admin_core/admin_core.service';
|
||||||
|
import { Users } from '../../core/db/users/users.entity';
|
||||||
|
import { UsersService } from '../../core/db/users/users.service';
|
||||||
|
import { UsersMemoryService } from '../../core/db/users/users_memory.service';
|
||||||
|
import { LogManagementService } from '../../core/utils/logger/log_management.service';
|
||||||
|
|
||||||
|
export interface AdminApiResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
message: string;
|
||||||
|
error_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminService {
|
||||||
|
private readonly logger = new Logger(AdminService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly adminCoreService: AdminCoreService,
|
||||||
|
@Inject('UsersService') private readonly usersService: UsersService | UsersMemoryService,
|
||||||
|
private readonly logManagementService: LogManagementService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getLogDirAbsolutePath(): string {
|
||||||
|
return this.logManagementService.getLogDirAbsolutePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(identifier: string, password: string): Promise<AdminApiResponse> {
|
||||||
|
try {
|
||||||
|
const result = await this.adminCoreService.login({ identifier, password });
|
||||||
|
return { success: true, data: result, message: '管理员登录成功' };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`管理员登录失败: ${identifier}`, error instanceof Error ? error.stack : String(error));
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : '管理员登录失败',
|
||||||
|
error_code: 'ADMIN_LOGIN_FAILED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUsers(limit: number, offset: number): Promise<AdminApiResponse<{ users: any[]; limit: number; offset: number }>> {
|
||||||
|
const users = await this.usersService.findAll(limit, offset);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
users: users.map((u: Users) => this.formatUser(u)),
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
},
|
||||||
|
message: '用户列表获取成功',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUser(id: bigint): Promise<AdminApiResponse<{ user: any }>> {
|
||||||
|
const user = await this.usersService.findOne(id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { user: this.formatUser(user) },
|
||||||
|
message: '用户信息获取成功',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(id: bigint, newPassword: string): Promise<AdminApiResponse> {
|
||||||
|
// 确认用户存在
|
||||||
|
const user = await this.usersService.findOne(id).catch((): null => null);
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('用户不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.adminCoreService.resetUserPassword(id, newPassword);
|
||||||
|
|
||||||
|
this.logger.log(`管理员重置密码成功: userId=${id.toString()}`);
|
||||||
|
|
||||||
|
return { success: true, message: '密码重置成功' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRuntimeLogs(lines?: number): Promise<AdminApiResponse<{ file: string; updated_at: string; lines: string[] }>> {
|
||||||
|
const result = await this.logManagementService.getRuntimeLogTail({ lines });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: '运行日志获取成功',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatUser(user: Users) {
|
||||||
|
return {
|
||||||
|
id: user.id.toString(),
|
||||||
|
username: user.username,
|
||||||
|
nickname: user.nickname,
|
||||||
|
email: user.email,
|
||||||
|
email_verified: user.email_verified,
|
||||||
|
phone: user.phone,
|
||||||
|
avatar_url: user.avatar_url,
|
||||||
|
role: user.role,
|
||||||
|
created_at: user.created_at,
|
||||||
|
updated_at: user.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/core/admin_core/admin_core.module.ts
Normal file
27
src/core/admin_core/admin_core.module.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 管理员核心模块
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 提供管理员登录鉴权能力(签名Token)
|
||||||
|
* - 提供管理员账户启动引导(可选)
|
||||||
|
* - 为业务层 AdminModule 提供可复用的核心服务
|
||||||
|
*
|
||||||
|
* 依赖模块:
|
||||||
|
* - UsersModule: 用户数据访问(数据库/内存双模式)
|
||||||
|
* - ConfigModule: 环境变量与配置读取
|
||||||
|
*
|
||||||
|
* @author jianuo
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-19
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { AdminCoreService } from './admin_core.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
providers: [AdminCoreService],
|
||||||
|
exports: [AdminCoreService],
|
||||||
|
})
|
||||||
|
export class AdminCoreModule {}
|
||||||
459
src/core/admin_core/admin_core.service.spec.ts
Normal file
459
src/core/admin_core/admin_core.service.spec.ts
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { AdminAuthPayload, AdminCoreService } from './admin_core.service';
|
||||||
|
import { Users } from '../db/users/users.entity';
|
||||||
|
|
||||||
|
jest.mock('bcrypt', () => ({
|
||||||
|
compare: jest.fn(),
|
||||||
|
hash: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type UsersServiceLike = {
|
||||||
|
findByUsername: jest.Mock;
|
||||||
|
findByEmail: jest.Mock;
|
||||||
|
findAll: jest.Mock;
|
||||||
|
update: jest.Mock;
|
||||||
|
create: jest.Mock;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AdminCoreService', () => {
|
||||||
|
let configService: Pick<ConfigService, 'get'>;
|
||||||
|
let usersService: UsersServiceLike;
|
||||||
|
let service: AdminCoreService;
|
||||||
|
|
||||||
|
const secret = '0123456789abcdef';
|
||||||
|
|
||||||
|
const signToken = (payload: AdminAuthPayload, tokenSecret: string): string => {
|
||||||
|
const payloadJson = JSON.stringify(payload);
|
||||||
|
const payloadPart = Buffer.from(payloadJson, 'utf8')
|
||||||
|
.toString('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/g, '');
|
||||||
|
|
||||||
|
const signature = crypto
|
||||||
|
.createHmac('sha256', tokenSecret)
|
||||||
|
.update(payloadPart)
|
||||||
|
.digest('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/g, '');
|
||||||
|
|
||||||
|
return `${payloadPart}.${signature}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
|
||||||
|
configService = {
|
||||||
|
get: jest.fn((key: string, defaultValue?: any) => {
|
||||||
|
if (key === 'ADMIN_TOKEN_SECRET') return secret;
|
||||||
|
if (key === 'ADMIN_TOKEN_TTL_SECONDS') return defaultValue ?? '28800';
|
||||||
|
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'false';
|
||||||
|
return defaultValue;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
usersService = {
|
||||||
|
findByUsername: jest.fn(),
|
||||||
|
findByEmail: jest.fn(),
|
||||||
|
findAll: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
service = new AdminCoreService(configService as ConfigService, usersService as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('should reject when admin does not exist', async () => {
|
||||||
|
usersService.findByUsername.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.login({ identifier: 'admin', password: 'Admin123456' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject non-admin user', async () => {
|
||||||
|
usersService.findByUsername.mockResolvedValue({
|
||||||
|
id: BigInt(1),
|
||||||
|
username: 'user',
|
||||||
|
nickname: 'U',
|
||||||
|
role: 1,
|
||||||
|
password_hash: 'hash',
|
||||||
|
} as unknown as Users);
|
||||||
|
|
||||||
|
await expect(service.login({ identifier: 'user', password: 'Admin123456' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject admin without password_hash', async () => {
|
||||||
|
usersService.findByUsername.mockResolvedValue({
|
||||||
|
id: BigInt(1),
|
||||||
|
username: 'admin',
|
||||||
|
nickname: '管理员',
|
||||||
|
role: 9,
|
||||||
|
password_hash: null,
|
||||||
|
} as unknown as Users);
|
||||||
|
|
||||||
|
await expect(service.login({ identifier: 'admin', password: 'Admin123456' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject wrong password', async () => {
|
||||||
|
usersService.findByUsername.mockResolvedValue({
|
||||||
|
id: BigInt(1),
|
||||||
|
username: 'admin',
|
||||||
|
nickname: '管理员',
|
||||||
|
role: 9,
|
||||||
|
password_hash: 'hash',
|
||||||
|
} as unknown as Users);
|
||||||
|
|
||||||
|
const bcryptAny = bcrypt as any;
|
||||||
|
bcryptAny.compare.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(service.login({ identifier: 'admin', password: 'bad' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should login with valid credentials and generate verifiable token', async () => {
|
||||||
|
const now = 1735689600000;
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||||
|
|
||||||
|
usersService.findByUsername.mockResolvedValue({
|
||||||
|
id: BigInt(1),
|
||||||
|
username: 'admin',
|
||||||
|
nickname: '管理员',
|
||||||
|
role: 9,
|
||||||
|
password_hash: 'hash',
|
||||||
|
} as unknown as Users);
|
||||||
|
|
||||||
|
const bcryptAny = bcrypt as any;
|
||||||
|
bcryptAny.compare.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await service.login({ identifier: 'admin', password: 'Admin123456' });
|
||||||
|
|
||||||
|
expect(result.admin).toEqual({ id: '1', username: 'admin', nickname: '管理员', role: 9 });
|
||||||
|
expect(result.access_token).toContain('.');
|
||||||
|
expect(result.expires_at).toBeGreaterThan(now);
|
||||||
|
|
||||||
|
const payload = service.verifyToken(result.access_token);
|
||||||
|
expect(payload).toMatchObject({ adminId: '1', username: 'admin', role: 9 });
|
||||||
|
expect(payload.iat).toBe(now);
|
||||||
|
expect(payload.exp).toBe(result.expires_at);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find admin by email identifier', async () => {
|
||||||
|
const now = 1735689600000;
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||||
|
|
||||||
|
usersService.findByUsername.mockResolvedValue(null);
|
||||||
|
usersService.findByEmail.mockResolvedValue({
|
||||||
|
id: BigInt(1),
|
||||||
|
username: 'admin',
|
||||||
|
nickname: '管理员',
|
||||||
|
role: 9,
|
||||||
|
password_hash: 'hash',
|
||||||
|
} as unknown as Users);
|
||||||
|
|
||||||
|
const bcryptAny = bcrypt as any;
|
||||||
|
bcryptAny.compare.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await service.login({ identifier: 'admin@test.com', password: 'Admin123456' });
|
||||||
|
expect(result.admin.role).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find admin by phone identifier', async () => {
|
||||||
|
const now = 1735689600000;
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||||
|
|
||||||
|
usersService.findByUsername.mockResolvedValue(null);
|
||||||
|
usersService.findByEmail.mockResolvedValue(null);
|
||||||
|
usersService.findAll.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: BigInt(2),
|
||||||
|
username: 'admin',
|
||||||
|
nickname: '管理员',
|
||||||
|
role: 9,
|
||||||
|
phone: '+86 13800000000',
|
||||||
|
password_hash: 'hash',
|
||||||
|
} as unknown as Users,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bcryptAny = bcrypt as any;
|
||||||
|
bcryptAny.compare.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await service.login({ identifier: '+86 13800000000', password: 'Admin123456' });
|
||||||
|
expect(result.admin.id).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return phone-matched user via findUserByIdentifier (coverage for line 168)', async () => {
|
||||||
|
usersService.findByUsername.mockResolvedValue(null);
|
||||||
|
usersService.findByEmail.mockResolvedValue(null);
|
||||||
|
usersService.findAll.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: BigInt(10),
|
||||||
|
username: 'admin',
|
||||||
|
nickname: '管理员',
|
||||||
|
role: 9,
|
||||||
|
phone: '13800000000',
|
||||||
|
} as unknown as Users,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const found = await (service as any).findUserByIdentifier('13800000000');
|
||||||
|
expect(found?.id?.toString()).toBe('10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to default TTL when ADMIN_TOKEN_TTL_SECONDS is invalid', async () => {
|
||||||
|
const now = 1735689600000;
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||||
|
|
||||||
|
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
|
||||||
|
if (key === 'ADMIN_TOKEN_SECRET') return secret;
|
||||||
|
if (key === 'ADMIN_TOKEN_TTL_SECONDS') return 'not-a-number';
|
||||||
|
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'false';
|
||||||
|
return defaultValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
usersService.findByUsername.mockResolvedValue({
|
||||||
|
id: BigInt(1),
|
||||||
|
username: 'admin',
|
||||||
|
nickname: '管理员',
|
||||||
|
role: 9,
|
||||||
|
password_hash: 'hash',
|
||||||
|
} as unknown as Users);
|
||||||
|
|
||||||
|
const bcryptAny = bcrypt as any;
|
||||||
|
bcryptAny.compare.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const result = await service.login({ identifier: 'admin', password: 'Admin123456' });
|
||||||
|
expect(result.expires_at).toBe(now + 28800 * 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyToken', () => {
|
||||||
|
it('should reject token when secret missing/too short', () => {
|
||||||
|
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
|
||||||
|
if (key === 'ADMIN_TOKEN_SECRET') return 'short';
|
||||||
|
return defaultValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => service.verifyToken('a.b')).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid token format', () => {
|
||||||
|
expect(() => service.verifyToken('no-dot')).toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject token when payload JSON cannot be parsed (but signature valid)', () => {
|
||||||
|
const payloadPart = Buffer.from('not-json', 'utf8')
|
||||||
|
.toString('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/g, '');
|
||||||
|
|
||||||
|
const signature = crypto
|
||||||
|
.createHmac('sha256', secret)
|
||||||
|
.update(payloadPart)
|
||||||
|
.digest('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/g, '');
|
||||||
|
|
||||||
|
expect(() => service.verifyToken(`${payloadPart}.${signature}`)).toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept valid signed token and return payload', () => {
|
||||||
|
const now = 1735689600000;
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||||
|
|
||||||
|
const payload: AdminAuthPayload = {
|
||||||
|
adminId: '1',
|
||||||
|
username: 'admin',
|
||||||
|
role: 9,
|
||||||
|
iat: now,
|
||||||
|
exp: now + 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = signToken(payload, secret);
|
||||||
|
expect(service.verifyToken(token)).toEqual(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject expired token', () => {
|
||||||
|
const now = 1735689600000;
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||||
|
|
||||||
|
const payload: AdminAuthPayload = {
|
||||||
|
adminId: '1',
|
||||||
|
username: 'admin',
|
||||||
|
role: 9,
|
||||||
|
iat: now - 1000,
|
||||||
|
exp: now - 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = signToken(payload, secret);
|
||||||
|
expect(() => service.verifyToken(token)).toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject token with invalid signature', () => {
|
||||||
|
const now = 1735689600000;
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||||
|
|
||||||
|
const payload: AdminAuthPayload = {
|
||||||
|
adminId: '1',
|
||||||
|
username: 'admin',
|
||||||
|
role: 9,
|
||||||
|
iat: now,
|
||||||
|
exp: now + 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = signToken(payload, 'different_secret_012345');
|
||||||
|
expect(() => service.verifyToken(token)).toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject token with non-admin role', () => {
|
||||||
|
const now = 1735689600000;
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||||
|
|
||||||
|
const payload: AdminAuthPayload = {
|
||||||
|
adminId: '1',
|
||||||
|
username: 'user',
|
||||||
|
role: 1,
|
||||||
|
iat: now,
|
||||||
|
exp: now + 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = signToken(payload, secret);
|
||||||
|
expect(() => service.verifyToken(token)).toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject token when signature length mismatches expected', () => {
|
||||||
|
const now = 1735689600000;
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||||
|
|
||||||
|
const payload: AdminAuthPayload = {
|
||||||
|
adminId: '1',
|
||||||
|
username: 'admin',
|
||||||
|
role: 9,
|
||||||
|
iat: now,
|
||||||
|
exp: now + 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Valid payloadPart, but deliberately wrong signature length
|
||||||
|
const payloadJson = JSON.stringify(payload);
|
||||||
|
const payloadPart = Buffer.from(payloadJson, 'utf8')
|
||||||
|
.toString('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/g, '');
|
||||||
|
|
||||||
|
expect(() => service.verifyToken(`${payloadPart}.x`)).toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetUserPassword', () => {
|
||||||
|
it('should update user password_hash when password is strong', async () => {
|
||||||
|
const bcryptAny = bcrypt as any;
|
||||||
|
bcryptAny.hash.mockResolvedValue('hashed');
|
||||||
|
usersService.update.mockResolvedValue({} as any);
|
||||||
|
|
||||||
|
await service.resetUserPassword(BigInt(5), 'NewPass1234');
|
||||||
|
|
||||||
|
expect(usersService.update).toHaveBeenCalledWith(BigInt(5), { password_hash: 'hashed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject weak password', async () => {
|
||||||
|
await expect(service.resetUserPassword(BigInt(5), 'short')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate password strength directly (letters + numbers, 8+)', () => {
|
||||||
|
expect(() => (service as any).validatePasswordStrength('12345678')).toThrow(BadRequestException);
|
||||||
|
expect(() => (service as any).validatePasswordStrength('abcdefgh')).toThrow(BadRequestException);
|
||||||
|
expect(() => (service as any).validatePasswordStrength('Abcdef12')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject too-long password (>128)', () => {
|
||||||
|
const long = `Abc1${'x'.repeat(200)}`;
|
||||||
|
expect(() => (service as any).validatePasswordStrength(long)).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bootstrapAdminIfEnabled', () => {
|
||||||
|
it('should do nothing when bootstrap disabled', async () => {
|
||||||
|
await service.onModuleInit();
|
||||||
|
expect(usersService.findByUsername).not.toHaveBeenCalled();
|
||||||
|
expect(usersService.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip when enabled but missing username/password', async () => {
|
||||||
|
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
|
||||||
|
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'true';
|
||||||
|
if (key === 'ADMIN_USERNAME') return undefined;
|
||||||
|
if (key === 'ADMIN_PASSWORD') return undefined;
|
||||||
|
if (key === 'ADMIN_NICKNAME') return defaultValue ?? '管理员';
|
||||||
|
if (key === 'ADMIN_TOKEN_SECRET') return secret;
|
||||||
|
return defaultValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.onModuleInit();
|
||||||
|
|
||||||
|
expect(usersService.findByUsername).not.toHaveBeenCalled();
|
||||||
|
expect(usersService.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip when existing user already present', async () => {
|
||||||
|
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
|
||||||
|
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'true';
|
||||||
|
if (key === 'ADMIN_USERNAME') return 'admin';
|
||||||
|
if (key === 'ADMIN_PASSWORD') return 'Admin123456';
|
||||||
|
if (key === 'ADMIN_NICKNAME') return '管理员';
|
||||||
|
if (key === 'ADMIN_TOKEN_SECRET') return secret;
|
||||||
|
return defaultValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
usersService.findByUsername.mockResolvedValue({ id: BigInt(1), username: 'admin', role: 9 } as any);
|
||||||
|
|
||||||
|
await service.onModuleInit();
|
||||||
|
|
||||||
|
expect(usersService.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip and warn when existing user has same username but non-admin role', async () => {
|
||||||
|
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
|
||||||
|
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'true';
|
||||||
|
if (key === 'ADMIN_USERNAME') return 'admin';
|
||||||
|
if (key === 'ADMIN_PASSWORD') return 'Admin123456';
|
||||||
|
if (key === 'ADMIN_NICKNAME') return '管理员';
|
||||||
|
if (key === 'ADMIN_TOKEN_SECRET') return secret;
|
||||||
|
return defaultValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
usersService.findByUsername.mockResolvedValue({ id: BigInt(1), username: 'admin', role: 1 } as any);
|
||||||
|
|
||||||
|
await service.onModuleInit();
|
||||||
|
expect(usersService.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create admin user when enabled and not existing', async () => {
|
||||||
|
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
|
||||||
|
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'true';
|
||||||
|
if (key === 'ADMIN_USERNAME') return 'admin';
|
||||||
|
if (key === 'ADMIN_PASSWORD') return 'Admin123456';
|
||||||
|
if (key === 'ADMIN_NICKNAME') return '管理员';
|
||||||
|
if (key === 'ADMIN_TOKEN_SECRET') return secret;
|
||||||
|
return defaultValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
usersService.findByUsername.mockResolvedValue(null);
|
||||||
|
const bcryptAny = bcrypt as any;
|
||||||
|
bcryptAny.hash.mockResolvedValue('hashed');
|
||||||
|
|
||||||
|
await service.onModuleInit();
|
||||||
|
|
||||||
|
expect(usersService.create).toHaveBeenCalledWith({
|
||||||
|
username: 'admin',
|
||||||
|
password_hash: 'hashed',
|
||||||
|
nickname: '管理员',
|
||||||
|
role: 9,
|
||||||
|
email_verified: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
285
src/core/admin_core/admin_core.service.ts
Normal file
285
src/core/admin_core/admin_core.service.ts
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* 管理员核心服务
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 管理员登录校验(仅允许 role=9)
|
||||||
|
* - 生成/验证管理员签名Token(HMAC-SHA256)
|
||||||
|
* - 启动时可选引导创建管理员账号(通过环境变量启用)
|
||||||
|
*
|
||||||
|
* 安全说明:
|
||||||
|
* - 本服务生成的Token为对称签名Token(非JWT标准实现),但具备有效期与签名校验
|
||||||
|
* - 仅用于后台管理用途;生产环境务必配置强随机的 ADMIN_TOKEN_SECRET
|
||||||
|
*
|
||||||
|
* @author jianuo
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-19
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BadRequestException, Inject, Injectable, Logger, OnModuleInit, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { Users } from '../db/users/users.entity';
|
||||||
|
import { UsersService } from '../db/users/users.service';
|
||||||
|
import { UsersMemoryService } from '../db/users/users_memory.service';
|
||||||
|
|
||||||
|
export interface AdminLoginRequest {
|
||||||
|
identifier: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAuthPayload {
|
||||||
|
adminId: string;
|
||||||
|
username: string;
|
||||||
|
role: number;
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminLoginResult {
|
||||||
|
admin: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
role: number;
|
||||||
|
};
|
||||||
|
access_token: string;
|
||||||
|
expires_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminCoreService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(AdminCoreService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
@Inject('UsersService') private readonly usersService: UsersService | UsersMemoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await this.bootstrapAdminIfEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员登录
|
||||||
|
*/
|
||||||
|
async login(request: AdminLoginRequest): Promise<AdminLoginResult> {
|
||||||
|
const { identifier, password } = request;
|
||||||
|
|
||||||
|
const adminUser = await this.findUserByIdentifier(identifier);
|
||||||
|
if (!adminUser) {
|
||||||
|
throw new UnauthorizedException('管理员账号不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminUser.role !== 9) {
|
||||||
|
throw new UnauthorizedException('无管理员权限');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!adminUser.password_hash) {
|
||||||
|
throw new UnauthorizedException('管理员账户未设置密码,无法登录');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await bcrypt.compare(password, adminUser.password_hash);
|
||||||
|
if (!ok) {
|
||||||
|
throw new UnauthorizedException('密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttlSeconds = this.getAdminTokenTtlSeconds();
|
||||||
|
const now = Date.now();
|
||||||
|
const payload: AdminAuthPayload = {
|
||||||
|
adminId: adminUser.id.toString(),
|
||||||
|
username: adminUser.username,
|
||||||
|
role: adminUser.role,
|
||||||
|
iat: now,
|
||||||
|
exp: now + ttlSeconds * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = this.signPayload(payload);
|
||||||
|
|
||||||
|
return {
|
||||||
|
admin: {
|
||||||
|
id: adminUser.id.toString(),
|
||||||
|
username: adminUser.username,
|
||||||
|
nickname: adminUser.nickname,
|
||||||
|
role: adminUser.role,
|
||||||
|
},
|
||||||
|
access_token: token,
|
||||||
|
expires_at: payload.exp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验管理员Token并返回Payload
|
||||||
|
*/
|
||||||
|
verifyToken(token: string): AdminAuthPayload {
|
||||||
|
const secret = this.getAdminTokenSecret();
|
||||||
|
const [payloadPart, signaturePart] = token.split('.');
|
||||||
|
|
||||||
|
if (!payloadPart || !signaturePart) {
|
||||||
|
throw new UnauthorizedException('Token格式错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = this.hmacSha256Base64Url(payloadPart, secret);
|
||||||
|
if (!this.safeEqual(signaturePart, expected)) {
|
||||||
|
throw new UnauthorizedException('Token签名无效');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadJson = Buffer.from(this.base64UrlToBase64(payloadPart), 'base64').toString('utf-8');
|
||||||
|
let payload: AdminAuthPayload;
|
||||||
|
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(payloadJson) as AdminAuthPayload;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Token解析失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload?.adminId || payload.role !== 9) {
|
||||||
|
throw new UnauthorizedException('无管理员权限');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof payload.exp !== 'number' || Date.now() > payload.exp) {
|
||||||
|
throw new UnauthorizedException('Token已过期');
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员重置用户密码(直接设置新密码)
|
||||||
|
*/
|
||||||
|
async resetUserPassword(userId: bigint, newPassword: string): Promise<void> {
|
||||||
|
this.validatePasswordStrength(newPassword);
|
||||||
|
|
||||||
|
const passwordHash = await this.hashPassword(newPassword);
|
||||||
|
await this.usersService.update(userId, { password_hash: passwordHash });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findUserByIdentifier(identifier: string): Promise<Users | null> {
|
||||||
|
const byUsername = await this.usersService.findByUsername(identifier);
|
||||||
|
if (byUsername) return byUsername;
|
||||||
|
|
||||||
|
if (this.isEmail(identifier)) {
|
||||||
|
const byEmail = await this.usersService.findByEmail(identifier);
|
||||||
|
if (byEmail) return byEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isPhoneNumber(identifier)) {
|
||||||
|
const users = await this.usersService.findAll(1000, 0);
|
||||||
|
return users.find((u: Users) => u.phone === identifier) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async bootstrapAdminIfEnabled(): Promise<void> {
|
||||||
|
const enabled = this.configService.get<string>('ADMIN_BOOTSTRAP_ENABLED', 'false') === 'true';
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const username = this.configService.get<string>('ADMIN_USERNAME');
|
||||||
|
const password = this.configService.get<string>('ADMIN_PASSWORD');
|
||||||
|
const nickname = this.configService.get<string>('ADMIN_NICKNAME', '管理员');
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
this.logger.warn('已启用管理员引导,但未配置 ADMIN_USERNAME / ADMIN_PASSWORD,跳过创建');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.usersService.findByUsername(username);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.role !== 9) {
|
||||||
|
this.logger.warn(`管理员引导发现同名用户但role!=9:${username},跳过`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validatePasswordStrength(password);
|
||||||
|
const passwordHash = await this.hashPassword(password);
|
||||||
|
|
||||||
|
await this.usersService.create({
|
||||||
|
username,
|
||||||
|
password_hash: passwordHash,
|
||||||
|
nickname,
|
||||||
|
role: 9,
|
||||||
|
email_verified: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`管理员账号已创建:${username} (role=9)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAdminTokenSecret(): string {
|
||||||
|
const secret = this.configService.get<string>('ADMIN_TOKEN_SECRET');
|
||||||
|
if (!secret || secret.length < 16) {
|
||||||
|
throw new BadRequestException('ADMIN_TOKEN_SECRET 未配置或过短(至少16字符)');
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAdminTokenTtlSeconds(): number {
|
||||||
|
const raw = this.configService.get<string>('ADMIN_TOKEN_TTL_SECONDS', '28800'); // 8h
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return 28800;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private signPayload(payload: AdminAuthPayload): string {
|
||||||
|
const secret = this.getAdminTokenSecret();
|
||||||
|
const payloadJson = JSON.stringify(payload);
|
||||||
|
const payloadPart = this.base64ToBase64Url(Buffer.from(payloadJson, 'utf-8').toString('base64'));
|
||||||
|
const signature = this.hmacSha256Base64Url(payloadPart, secret);
|
||||||
|
return `${payloadPart}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hmacSha256Base64Url(data: string, secret: string): string {
|
||||||
|
const digest = crypto.createHmac('sha256', secret).update(data).digest('base64');
|
||||||
|
return this.base64ToBase64Url(digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
private base64ToBase64Url(base64: string): string {
|
||||||
|
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private base64UrlToBase64(base64Url: string): string {
|
||||||
|
const padded = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padLen = (4 - (padded.length % 4)) % 4;
|
||||||
|
return padded + '='.repeat(padLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeEqual(a: string, b: string): boolean {
|
||||||
|
const aBuf = Buffer.from(a);
|
||||||
|
const bBuf = Buffer.from(b);
|
||||||
|
if (aBuf.length !== bBuf.length) return false;
|
||||||
|
return crypto.timingSafeEqual(aBuf, bBuf);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isEmail(value: string): boolean {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPhoneNumber(value: string): boolean {
|
||||||
|
return /^\+?[0-9\-\s]{6,20}$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private validatePasswordStrength(password: string): void {
|
||||||
|
if (password.length < 8) {
|
||||||
|
throw new BadRequestException('密码长度至少8位');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length > 128) {
|
||||||
|
throw new BadRequestException('密码长度不能超过128位');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLetter = /[a-zA-Z]/.test(password);
|
||||||
|
const hasNumber = /\d/.test(password);
|
||||||
|
|
||||||
|
if (!hasLetter || !hasNumber) {
|
||||||
|
throw new BadRequestException('密码必须包含字母和数字');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hashPassword(password: string): Promise<string> {
|
||||||
|
const saltRounds = 12;
|
||||||
|
return await bcrypt.hash(password, saltRounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/core/guards/admin.guard.spec.ts
Normal file
81
src/core/guards/admin.guard.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { AdminCoreService, AdminAuthPayload } from '../admin_core/admin_core.service';
|
||||||
|
import { AdminGuard } from './admin.guard';
|
||||||
|
|
||||||
|
describe('AdminGuard', () => {
|
||||||
|
const payload: AdminAuthPayload = {
|
||||||
|
adminId: '1',
|
||||||
|
username: 'admin',
|
||||||
|
role: 9,
|
||||||
|
iat: 1,
|
||||||
|
exp: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminCoreServiceMock: Pick<AdminCoreService, 'verifyToken'> = {
|
||||||
|
verifyToken: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeContext = (authorization?: any) => {
|
||||||
|
const req: any = { headers: {} };
|
||||||
|
if (authorization !== undefined) {
|
||||||
|
req.headers['authorization'] = authorization;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx: Partial<ExecutionContext> = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => req,
|
||||||
|
getResponse: () => ({} as any),
|
||||||
|
getNext: () => ({} as any),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ctx: ctx as ExecutionContext, req };
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow access with valid admin token', () => {
|
||||||
|
(adminCoreServiceMock.verifyToken as jest.Mock).mockReturnValue(payload);
|
||||||
|
|
||||||
|
const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService);
|
||||||
|
const { ctx, req } = makeContext('Bearer valid');
|
||||||
|
|
||||||
|
expect(guard.canActivate(ctx)).toBe(true);
|
||||||
|
expect(adminCoreServiceMock.verifyToken).toHaveBeenCalledWith('valid');
|
||||||
|
expect(req.admin).toEqual(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny access without token', () => {
|
||||||
|
const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService);
|
||||||
|
const { ctx } = makeContext(undefined);
|
||||||
|
|
||||||
|
expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny access with invalid Authorization format', () => {
|
||||||
|
const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService);
|
||||||
|
const { ctx } = makeContext('InvalidFormat');
|
||||||
|
|
||||||
|
expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny access when verifyToken throws (invalid/expired)', () => {
|
||||||
|
(adminCoreServiceMock.verifyToken as jest.Mock).mockImplementation(() => {
|
||||||
|
throw new UnauthorizedException('Token已过期');
|
||||||
|
});
|
||||||
|
|
||||||
|
const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService);
|
||||||
|
const { ctx } = makeContext('Bearer bad');
|
||||||
|
|
||||||
|
expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny access when Authorization header is an array', () => {
|
||||||
|
const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService);
|
||||||
|
const { ctx } = makeContext(['Bearer token']);
|
||||||
|
|
||||||
|
expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
43
src/core/guards/admin.guard.ts
Normal file
43
src/core/guards/admin.guard.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 管理员鉴权守卫
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 保护后台管理接口
|
||||||
|
* - 校验 Authorization: Bearer <admin_token>
|
||||||
|
* - 仅允许 role=9 的管理员访问
|
||||||
|
*
|
||||||
|
* @author jianuo
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-19
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { AdminCoreService, AdminAuthPayload } from '../admin_core/admin_core.service';
|
||||||
|
|
||||||
|
export interface AdminRequest extends Request {
|
||||||
|
admin?: AdminAuthPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminGuard implements CanActivate {
|
||||||
|
constructor(private readonly adminCoreService: AdminCoreService) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const req = context.switchToHttp().getRequest<AdminRequest>();
|
||||||
|
const auth = req.headers['authorization'];
|
||||||
|
|
||||||
|
if (!auth || Array.isArray(auth)) {
|
||||||
|
throw new UnauthorizedException('缺少Authorization头');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [scheme, token] = auth.split(' ');
|
||||||
|
if (scheme !== 'Bearer' || !token) {
|
||||||
|
throw new UnauthorizedException('Authorization格式错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = this.adminCoreService.verifyToken(token);
|
||||||
|
req.admin = payload;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@ describe('LoginCoreService', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
LoginCoreService,
|
LoginCoreService,
|
||||||
{
|
{
|
||||||
provide: UsersService,
|
provide: 'UsersService',
|
||||||
useValue: mockUsersService,
|
useValue: mockUsersService,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -70,7 +70,7 @@ describe('LoginCoreService', () => {
|
|||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<LoginCoreService>(LoginCoreService);
|
service = module.get<LoginCoreService>(LoginCoreService);
|
||||||
usersService = module.get(UsersService);
|
usersService = module.get('UsersService');
|
||||||
emailService = module.get(EmailService);
|
emailService = module.get(EmailService);
|
||||||
verificationService = module.get(VerificationService);
|
verificationService = module.get(VerificationService);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ export class LogManagementService {
|
|||||||
this.maxSize = this.configService.get('LOG_MAX_SIZE', '10m');
|
this.maxSize = this.configService.get('LOG_MAX_SIZE', '10m');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取日志目录的绝对路径
|
||||||
|
*
|
||||||
|
* 说明:用于后台打包下载 logs/ 整目录。
|
||||||
|
*/
|
||||||
|
getLogDirAbsolutePath(): string {
|
||||||
|
return path.resolve(this.logDir);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 定期清理过期日志文件
|
* 定期清理过期日志文件
|
||||||
*
|
*
|
||||||
@@ -307,6 +316,67 @@ export class LogManagementService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取运行日志尾部(用于后台查看)
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - 开发环境默认读取 dev.log
|
||||||
|
* - 生产环境默认读取 app.log(可选 access/error)
|
||||||
|
* - 通过读取文件尾部一定字节数实现“近似 tail”,避免大文件全量读取
|
||||||
|
*/
|
||||||
|
async getRuntimeLogTail(options?: {
|
||||||
|
type?: 'app' | 'access' | 'error' | 'dev';
|
||||||
|
lines?: number;
|
||||||
|
}): Promise<{
|
||||||
|
file: string;
|
||||||
|
updated_at: string;
|
||||||
|
lines: string[];
|
||||||
|
}> {
|
||||||
|
const isProduction = this.configService.get('NODE_ENV') === 'production';
|
||||||
|
const requestedLines = Math.max(1, Math.min(Number(options?.lines ?? 200), 2000));
|
||||||
|
const requestedType = options?.type;
|
||||||
|
|
||||||
|
const allowedFiles = isProduction
|
||||||
|
? {
|
||||||
|
app: 'app.log',
|
||||||
|
access: 'access.log',
|
||||||
|
error: 'error.log',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
dev: 'dev.log',
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultType = isProduction ? 'app' : 'dev';
|
||||||
|
const typeKey = (requestedType && requestedType in allowedFiles ? requestedType : defaultType) as keyof typeof allowedFiles;
|
||||||
|
const fileName = allowedFiles[typeKey];
|
||||||
|
const filePath = path.join(this.logDir, fileName);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return { file: fileName, updated_at: new Date().toISOString(), lines: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
const maxBytes = 256 * 1024; // 256KB 足够覆盖常见的数百行日志
|
||||||
|
const readBytes = Math.min(stats.size, maxBytes);
|
||||||
|
const startPos = Math.max(0, stats.size - readBytes);
|
||||||
|
|
||||||
|
const fd = fs.openSync(filePath, 'r');
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(readBytes);
|
||||||
|
fs.readSync(fd, buffer, 0, readBytes, startPos);
|
||||||
|
const text = buffer.toString('utf8');
|
||||||
|
const allLines = text.split(/\r?\n/).filter((l) => l.length > 0);
|
||||||
|
const tailLines = allLines.slice(-requestedLines);
|
||||||
|
return {
|
||||||
|
file: fileName,
|
||||||
|
updated_at: stats.mtime.toISOString(),
|
||||||
|
lines: tailLines,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析最大文件数配置
|
* 解析最大文件数配置
|
||||||
*
|
*
|
||||||
|
|||||||
34
src/dto/admin.dto.ts
Normal file
34
src/dto/admin.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 管理员相关 DTO
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 定义管理员登录与用户密码重置的请求结构
|
||||||
|
* - 使用 class-validator 进行参数校验
|
||||||
|
*
|
||||||
|
* @author jianuo
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-19
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class AdminLoginDto {
|
||||||
|
@ApiProperty({ description: '登录标识符(用户名/邮箱/手机号)', example: 'admin' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
identifier: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '密码', example: 'Admin123456' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminResetPasswordDto {
|
||||||
|
@ApiProperty({ description: '新密码(至少8位,包含字母和数字)', example: 'NewPass1234' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(8)
|
||||||
|
new_password: string;
|
||||||
|
}
|
||||||
164
src/dto/admin_response.dto.ts
Normal file
164
src/dto/admin_response.dto.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* 管理员相关响应 DTO
|
||||||
|
*
|
||||||
|
* 功能描述:
|
||||||
|
* - 为 Swagger 提供明确的响应结构定义
|
||||||
|
* - 与 AdminService 返回结构保持一致
|
||||||
|
*
|
||||||
|
* @author jianuo
|
||||||
|
* @version 1.0.0
|
||||||
|
* @since 2025-12-19
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
class AdminInfoDto {
|
||||||
|
@ApiProperty({ example: '1' })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'admin' })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '管理员' })
|
||||||
|
nickname: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 9 })
|
||||||
|
role: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminLoginDataDto {
|
||||||
|
@ApiProperty({ type: AdminInfoDto })
|
||||||
|
admin: AdminInfoDto;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '管理员访问Token(用于Authorization Bearer)' })
|
||||||
|
access_token: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '过期时间戳(毫秒)', example: 1766102400000 })
|
||||||
|
expires_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminLoginResponseDto {
|
||||||
|
@ApiProperty({ example: true })
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ type: AdminLoginDataDto, required: false })
|
||||||
|
data?: AdminLoginDataDto;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '管理员登录成功' })
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 'ADMIN_LOGIN_FAILED' })
|
||||||
|
error_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminUserDto {
|
||||||
|
@ApiProperty({ example: '1' })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'user1' })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '小明' })
|
||||||
|
nickname: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 'user1@example.com', nullable: true })
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: false })
|
||||||
|
email_verified: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: '+8613800138000', nullable: true })
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 'https://example.com/avatar.png', nullable: true })
|
||||||
|
avatar_url?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 1 })
|
||||||
|
role: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2025-12-19T00:00:00.000Z' })
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2025-12-19T00:00:00.000Z' })
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminUsersDataDto {
|
||||||
|
@ApiProperty({ type: [AdminUserDto] })
|
||||||
|
users: AdminUserDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ example: 100 })
|
||||||
|
limit: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 0 })
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminUsersResponseDto {
|
||||||
|
@ApiProperty({ example: true })
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ type: AdminUsersDataDto, required: false })
|
||||||
|
data?: AdminUsersDataDto;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '用户列表获取成功' })
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 'ADMIN_USERS_FAILED' })
|
||||||
|
error_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminUserDataDto {
|
||||||
|
@ApiProperty({ type: AdminUserDto })
|
||||||
|
user: AdminUserDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminUserResponseDto {
|
||||||
|
@ApiProperty({ example: true })
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ type: AdminUserDataDto, required: false })
|
||||||
|
data?: AdminUserDataDto;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '用户信息获取成功' })
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminCommonResponseDto {
|
||||||
|
@ApiProperty({ example: true })
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
data?: any;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '密码重置成功' })
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 'ADMIN_OPERATION_FAILED' })
|
||||||
|
error_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminRuntimeLogsDataDto {
|
||||||
|
@ApiProperty({ example: 'dev.log' })
|
||||||
|
file: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '日志文件最后更新时间(ISO)', example: '2025-12-19T19:10:15.000Z' })
|
||||||
|
updated_at: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String], description: '日志行(按时间顺序,越靠后越新)' })
|
||||||
|
lines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminRuntimeLogsResponseDto {
|
||||||
|
@ApiProperty({ example: true })
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ type: AdminRuntimeLogsDataDto, required: false })
|
||||||
|
data?: AdminRuntimeLogsDataDto;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '运行日志获取成功' })
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 'ADMIN_OPERATION_FAILED' })
|
||||||
|
error_code?: string;
|
||||||
|
}
|
||||||
@@ -39,6 +39,12 @@ async function bootstrap() {
|
|||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create(AppModule, {
|
||||||
logger: ['error', 'warn', 'log'],
|
logger: ['error', 'warn', 'log'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 允许前端后台(如Vite/React)跨域访问
|
||||||
|
app.enableCors({
|
||||||
|
origin: true,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
// 全局启用校验管道(核心配置)
|
// 全局启用校验管道(核心配置)
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
@@ -55,6 +61,7 @@ async function bootstrap() {
|
|||||||
.setDescription('像素游戏服务器API文档 - 包含用户认证、登录注册等功能')
|
.setDescription('像素游戏服务器API文档 - 包含用户认证、登录注册等功能')
|
||||||
.setVersion('1.0.0')
|
.setVersion('1.0.0')
|
||||||
.addTag('auth', '用户认证相关接口')
|
.addTag('auth', '用户认证相关接口')
|
||||||
|
.addTag('admin', '管理员后台相关接口')
|
||||||
.addBearerAuth(
|
.addBearerAuth(
|
||||||
{
|
{
|
||||||
type: 'http',
|
type: 'http',
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"module": "CommonJS",
|
"module": "Node16",
|
||||||
"lib": ["ES2020"],
|
"lib": ["ES2020"],
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node16",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"baseUrl": "./",
|
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"strictNullChecks": false,
|
"strictNullChecks": false,
|
||||||
"typeRoots": ["./node_modules/@types"]
|
"typeRoots": ["./node_modules/@types"]
|
||||||
|
|||||||
Reference in New Issue
Block a user