From dd4fb6edd31c405c98ca8337fca4ccd40d3ad652 Mon Sep 17 00:00:00 2001 From: jianuo <32106500027@e.gzhu.edu.cn> Date: Fri, 19 Dec 2025 19:17:47 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E7=AE=80=E5=8D=95=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E7=AE=A1=E7=90=86=E5=91=98=E5=90=8E=E5=8F=B0=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 14 + .env.production.example | 10 + README.md | 18 ++ client/.env.example | 4 + client/index.html | 12 + client/package.json | 24 ++ client/src/app/AdminLayout.tsx | 52 ++++ client/src/app/App.tsx | 26 ++ client/src/lib/adminAuth.ts | 17 ++ client/src/lib/api.ts | 62 ++++ client/src/main.tsx | 9 + client/src/pages/LoginPage.tsx | 50 +++ client/src/pages/UsersPage.tsx | 161 ++++++++++ client/tsconfig.json | 19 ++ client/vite.config.ts | 9 + docs/api/api-documentation.md | 48 ++- docs/systems/admin-dashboard/README.md | 156 ++++++++++ pnpm-workspace.yaml | 3 + src/app.module.ts | 2 + src/business/admin/admin.controller.ts | 75 +++++ src/business/admin/admin.module.ts | 24 ++ src/business/admin/admin.service.ts | 100 ++++++ src/core/admin_core/admin_core.module.ts | 27 ++ src/core/admin_core/admin_core.service.ts | 285 ++++++++++++++++++ src/core/guards/admin.guard.ts | 43 +++ .../login_core/login_core.service.spec.ts | 4 +- src/dto/admin.dto.ts | 34 +++ src/dto/admin_response.dto.ts | 139 +++++++++ src/main.ts | 7 + 29 files changed, 1431 insertions(+), 3 deletions(-) create mode 100644 client/.env.example create mode 100644 client/index.html create mode 100644 client/package.json create mode 100644 client/src/app/AdminLayout.tsx create mode 100644 client/src/app/App.tsx create mode 100644 client/src/lib/adminAuth.ts create mode 100644 client/src/lib/api.ts create mode 100644 client/src/main.tsx create mode 100644 client/src/pages/LoginPage.tsx create mode 100644 client/src/pages/UsersPage.tsx create mode 100644 client/tsconfig.json create mode 100644 client/vite.config.ts create mode 100644 docs/systems/admin-dashboard/README.md create mode 100644 src/business/admin/admin.controller.ts create mode 100644 src/business/admin/admin.module.ts create mode 100644 src/business/admin/admin.service.ts create mode 100644 src/core/admin_core/admin_core.module.ts create mode 100644 src/core/admin_core/admin_core.service.ts create mode 100644 src/core/guards/admin.guard.ts create mode 100644 src/dto/admin.dto.ts create mode 100644 src/dto/admin_response.dto.ts diff --git a/.env.example b/.env.example index 5e971bf..810ab46 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,20 @@ NODE_ENV=development PORT=3000 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_SECRET=test_jwt_secret_key_for_development_only_32chars JWT_EXPIRES_IN=7d diff --git a/.env.production.example b/.env.production.example index 80d0757..8cbf4f2 100644 --- a/.env.production.example +++ b/.env.production.example @@ -16,6 +16,16 @@ PORT=3000 JWT_SECRET=your_jwt_secret_key_here_at_least_32_characters 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服务 USE_FILE_REDIS=false diff --git a/README.md b/README.md index 8208fb4..79da810 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,24 @@ pnpm run dev 🎉 **服务启动成功!** 访问 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 diff --git a/client/.env.example b/client/.env.example new file mode 100644 index 0000000..227a209 --- /dev/null +++ b/client/.env.example @@ -0,0 +1,4 @@ +# 前端后台配置 +# 复制为 .env.local + +VITE_API_BASE_URL=http://localhost:3000 diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..8489477 --- /dev/null +++ b/client/index.html @@ -0,0 +1,12 @@ + + + + + + Whale Town Admin + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..80c9559 --- /dev/null +++ b/client/package.json @@ -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" + } +} diff --git a/client/src/app/AdminLayout.tsx b/client/src/app/AdminLayout.tsx new file mode 100644 index 0000000..0ce9802 --- /dev/null +++ b/client/src/app/AdminLayout.tsx @@ -0,0 +1,52 @@ +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('/users') ? 'users' : 'users'; + + return ( + + +
+ + Whale Town Admin + +
+ navigate('/users'), + }, + { + key: 'logout', + label: '退出登录', + onClick: () => { + clearAuth(); + navigate('/login'); + }, + }, + ]} + /> + + +
+ 后台管理 +
+ + + +
+ + ); +} diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx new file mode 100644 index 0000000..b7f8f6f --- /dev/null +++ b/client/src/app/App.tsx @@ -0,0 +1,26 @@ +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 { isAuthed } from '../lib/adminAuth'; + +export function App() { + return ( + + + + } /> + : } + > + } /> + } /> + + } /> + + + + ); +} diff --git a/client/src/lib/adminAuth.ts b/client/src/lib/adminAuth.ts new file mode 100644 index 0000000..5cc4508 --- /dev/null +++ b/client/src/lib/adminAuth.ts @@ -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()); +} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts new file mode 100644 index 0000000..720a65a --- /dev/null +++ b/client/src/lib/api.ts @@ -0,0 +1,62 @@ +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; + } +} + +async function request(path: string, init?: RequestInit): Promise { + const token = getToken(); + + const headers: Record = { + '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; +} + +export const api = { + adminLogin: (identifier: string, password: string) => + request('/admin/auth/login', { + method: 'POST', + body: JSON.stringify({ identifier, password }), + }), + + listUsers: (limit = 100, offset = 0) => + request(`/admin/users?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`), + + resetUserPassword: (userId: string, newPassword: string) => + request(`/admin/users/${encodeURIComponent(userId)}/reset-password`, { + method: 'POST', + body: JSON.stringify({ new_password: newPassword }), + }), +}; diff --git a/client/src/main.tsx b/client/src/main.tsx new file mode 100644 index 0000000..825cc9c --- /dev/null +++ b/client/src/main.tsx @@ -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( + + + , +); diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx new file mode 100644 index 0000000..ecccf07 --- /dev/null +++ b/client/src/pages/LoginPage.tsx @@ -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(); + + 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 ( +
+ + + 管理员登录 + +
+ + + + + + + +
+
+
+ ); +} diff --git a/client/src/pages/UsersPage.tsx b/client/src/pages/UsersPage.tsx new file mode 100644 index 0000000..c2c1a9e --- /dev/null +++ b/client/src/pages/UsersPage.tsx @@ -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([]); + const [resetOpen, setResetOpen] = useState(false); + const [resetUserId, setResetUserId] = useState(null); + const [resetForm] = Form.useForm(); + + 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) => ( + + + + ), + }, + ], + [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 ( + + + + + 用户管理 + + + + + + + + setResetOpen(false)} + okText="确认" + cancelText="取消" + > +
+ { + 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(); + }, + }, + ]} + > + + + +
+ + ); +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..cac61f2 --- /dev/null +++ b/client/tsconfig.json @@ -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"] +} diff --git a/client/vite.config.ts b/client/vite.config.ts new file mode 100644 index 0000000..5c59447 --- /dev/null +++ b/client/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + }, +}); diff --git a/docs/api/api-documentation.md b/docs/api/api-documentation.md index 3c5199d..e7a7567 100644 --- a/docs/api/api-documentation.md +++ b/docs/api/api-documentation.md @@ -409,4 +409,50 @@ curl -X POST http://localhost:3000/auth/reset-password \ ## 更新日志 -- **v1.0.0** (2025-12-17): 初始版本,包含基础的用户认证功能 \ No newline at end of file +- **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:管理员) | diff --git a/docs/systems/admin-dashboard/README.md b/docs/systems/admin-dashboard/README.md new file mode 100644 index 0000000..2206a0a --- /dev/null +++ b/docs/systems/admin-dashboard/README.md @@ -0,0 +1,156 @@ +# 管理员后台(Admin Dashboard) + +本模块提供 Whale Town 的管理员后台能力,包含: + +- 管理员登录(role=9) +- 用户列表管理 +- 用户密码重置 + +> 说明:本项目用户系统原本的 `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 ` + +--- + +## 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" +} +``` + +--- + +## 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` + +### 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`:用户管理页(列表+重置密码) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1b87ff3..ec28fb7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,6 @@ +packages: + - 'client' + ignoredBuiltDependencies: - '@nestjs/core' - '@scarf/scarf' diff --git a/src/app.module.ts b/src/app.module.ts index 12d727f..4d0b2d2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,7 @@ import { UsersModule } from './core/db/users/users.module'; import { LoginCoreModule } from './core/login_core/login_core.module'; import { LoginModule } from './business/login/login.module'; import { RedisModule } from './core/redis/redis.module'; +import { AdminModule } from './business/admin/admin.module'; /** * 检查数据库配置是否完整 by angjustinl 2025-12-17 @@ -61,6 +62,7 @@ function isDatabaseConfigured(): boolean { isDatabaseConfigured() ? UsersModule.forDatabase() : UsersModule.forMemory(), LoginCoreModule, LoginModule, + AdminModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/business/admin/admin.controller.ts b/src/business/admin/admin.controller.ts new file mode 100644 index 0000000..fb0bcc3 --- /dev/null +++ b/src/business/admin/admin.controller.ts @@ -0,0 +1,75 @@ +/** + * 管理员控制器 + * + * API端点: + * - POST /admin/auth/login 管理员登录 + * - GET /admin/users 用户列表(需要管理员Token) + * - GET /admin/users/:id 用户详情(需要管理员Token) + * - POST /admin/users/:id/reset-password 重置指定用户密码(需要管理员Token) + * + * @author jianuo + * @version 1.0.0 + * @since 2025-12-19 + */ + +import { Body, Controller, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Query, UseGuards, ValidationPipe, UsePipes } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, 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 } from '../../dto/admin_response.dto'; + +@ApiTags('admin') +@Controller('admin') +export class AdminController { + 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); + } +} diff --git a/src/business/admin/admin.module.ts b/src/business/admin/admin.module.ts new file mode 100644 index 0000000..3090700 --- /dev/null +++ b/src/business/admin/admin.module.ts @@ -0,0 +1,24 @@ +/** + * 管理员业务模块 + * + * 功能描述: + * - 提供后台管理的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 { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; + +@Module({ + imports: [AdminCoreModule], + controllers: [AdminController], + providers: [AdminService], +}) +export class AdminModule {} diff --git a/src/business/admin/admin.service.ts b/src/business/admin/admin.service.ts new file mode 100644 index 0000000..80b2503 --- /dev/null +++ b/src/business/admin/admin.service.ts @@ -0,0 +1,100 @@ +/** + * 管理员业务服务 + * + * 功能描述: + * - 调用核心服务完成管理员登录 + * - 提供用户列表查询 + * - 提供用户密码重置能力 + * + * @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'; + +export interface AdminApiResponse { + 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, + ) {} + + async login(identifier: string, password: string): Promise { + 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> { + 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> { + const user = await this.usersService.findOne(id); + return { + success: true, + data: { user: this.formatUser(user) }, + message: '用户信息获取成功', + }; + } + + async resetPassword(id: bigint, newPassword: string): Promise { + // 确认用户存在 + 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: '密码重置成功' }; + } + + 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, + }; + } +} diff --git a/src/core/admin_core/admin_core.module.ts b/src/core/admin_core/admin_core.module.ts new file mode 100644 index 0000000..2a31a83 --- /dev/null +++ b/src/core/admin_core/admin_core.module.ts @@ -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 {} diff --git a/src/core/admin_core/admin_core.service.ts b/src/core/admin_core/admin_core.service.ts new file mode 100644 index 0000000..f3c4848 --- /dev/null +++ b/src/core/admin_core/admin_core.service.ts @@ -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 { + await this.bootstrapAdminIfEnabled(); + } + + /** + * 管理员登录 + */ + async login(request: AdminLoginRequest): Promise { + 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 { + this.validatePasswordStrength(newPassword); + + const passwordHash = await this.hashPassword(newPassword); + await this.usersService.update(userId, { password_hash: passwordHash }); + } + + private async findUserByIdentifier(identifier: string): Promise { + 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 { + const enabled = this.configService.get('ADMIN_BOOTSTRAP_ENABLED', 'false') === 'true'; + if (!enabled) return; + + const username = this.configService.get('ADMIN_USERNAME'); + const password = this.configService.get('ADMIN_PASSWORD'); + const nickname = this.configService.get('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('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('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 { + const saltRounds = 12; + return await bcrypt.hash(password, saltRounds); + } +} diff --git a/src/core/guards/admin.guard.ts b/src/core/guards/admin.guard.ts new file mode 100644 index 0000000..d2c8296 --- /dev/null +++ b/src/core/guards/admin.guard.ts @@ -0,0 +1,43 @@ +/** + * 管理员鉴权守卫 + * + * 功能描述: + * - 保护后台管理接口 + * - 校验 Authorization: Bearer + * - 仅允许 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(); + 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; + } +} diff --git a/src/core/login_core/login_core.service.spec.ts b/src/core/login_core/login_core.service.spec.ts index 6d7c47a..0c95a79 100644 --- a/src/core/login_core/login_core.service.spec.ts +++ b/src/core/login_core/login_core.service.spec.ts @@ -55,7 +55,7 @@ describe('LoginCoreService', () => { providers: [ LoginCoreService, { - provide: UsersService, + provide: 'UsersService', useValue: mockUsersService, }, { @@ -70,7 +70,7 @@ describe('LoginCoreService', () => { }).compile(); service = module.get(LoginCoreService); - usersService = module.get(UsersService); + usersService = module.get('UsersService'); emailService = module.get(EmailService); verificationService = module.get(VerificationService); }); diff --git a/src/dto/admin.dto.ts b/src/dto/admin.dto.ts new file mode 100644 index 0000000..420f387 --- /dev/null +++ b/src/dto/admin.dto.ts @@ -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; +} diff --git a/src/dto/admin_response.dto.ts b/src/dto/admin_response.dto.ts new file mode 100644 index 0000000..07f7dc0 --- /dev/null +++ b/src/dto/admin_response.dto.ts @@ -0,0 +1,139 @@ +/** + * 管理员相关响应 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; +} diff --git a/src/main.ts b/src/main.ts index 279f830..a917c46 100644 --- a/src/main.ts +++ b/src/main.ts @@ -39,6 +39,12 @@ async function bootstrap() { const app = await NestFactory.create(AppModule, { logger: ['error', 'warn', 'log'], }); + + // 允许前端后台(如Vite/React)跨域访问 + app.enableCors({ + origin: true, + credentials: true, + }); // 全局启用校验管道(核心配置) app.useGlobalPipes( @@ -55,6 +61,7 @@ async function bootstrap() { .setDescription('像素游戏服务器API文档 - 包含用户认证、登录注册等功能') .setVersion('1.0.0') .addTag('auth', '用户认证相关接口') + .addTag('admin', '管理员后台相关接口') .addBearerAuth( { type: 'http',