feat:初始化项目
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { WordsModule } from './modules/words/words.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
import { User } from './modules/users/user.entity';
|
||||
import { Word } from './modules/words/word.entity';
|
||||
import { WordImage } from './modules/words/word-image.entity';
|
||||
import { Sentence } from './modules/words/sentence.entity';
|
||||
import { WordSentence } from './modules/words/word-sentence.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: [`.env.${process.env.NODE_ENV || 'development'}`, '.env'],
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: configService.get('DB_HOST', 'localhost'),
|
||||
port: configService.get<number>('DB_PORT', 5432),
|
||||
username: configService.get('DB_USERNAME'),
|
||||
password: configService.get('DB_PASSWORD'),
|
||||
database: configService.get('DB_DATABASE', 'doodle'),
|
||||
entities: [Word, WordImage, Sentence, WordSentence, User],
|
||||
synchronize: configService.get('NODE_ENV') !== 'production', // 生产环境应设为false
|
||||
logging: configService.get('NODE_ENV') === 'development',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
WordsModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Word 相关实体已移动到 modules/words 目录
|
||||
export * from '../modules/users/user.entity';
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// 启用 CORS - 支持多个跨域域名
|
||||
const corsOrigin = process.env.CORS_ORIGIN || 'http://localhost:3300';
|
||||
const allowedOrigins = corsOrigin.split(',').map((origin) => origin.trim());
|
||||
|
||||
app.enableCors({
|
||||
origin: allowedOrigins.length === 1 ? allowedOrigins[0] : allowedOrigins,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// 启用全局验证管道
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// 配置 Swagger
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('涂鸦丫后台管理系统 API')
|
||||
.setDescription('涂鸦丫后台管理系统 API 文档')
|
||||
.setVersion('1.0')
|
||||
.addTag('words', '汉字管理相关接口')
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api-docs', app, document);
|
||||
|
||||
const port = process.env.PORT || 3301;
|
||||
await app.listen(port);
|
||||
console.log(`Application is running on: http://localhost:${port}`);
|
||||
console.log(`Swagger API docs available at: http://localhost:${port}/api-docs`);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Controller, Post, Body, HttpCode, HttpStatus, Ip } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { LoginResponse } from './interfaces/login-response.interface';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '用户登录',
|
||||
description: '使用用户名/邮箱和密码登录,返回 access_token 和用户信息',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '登录成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
},
|
||||
user: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'number', example: 1 },
|
||||
username: { type: 'string', example: 'admin' },
|
||||
email: { type: 'string', example: 'admin@example.com' },
|
||||
role: { type: 'string', example: 'admin' },
|
||||
status: { type: 'string', example: 'active' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 401, description: '用户名或密码错误' })
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
async login(@Body() loginDto: LoginDto, @Ip() ip: string): Promise<LoginResponse> {
|
||||
return this.authService.login(loginDto, ip);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { User } from '../users/user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User]),
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
// @ts-expect-error - JWT expiresIn accepts string but type definition is strict
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const expiresIn = configService.get<string>('JWT_EXPIRES_IN', '7d');
|
||||
const secret = configService.get<string>(
|
||||
'JWT_SECRET',
|
||||
'your-secret-key-change-in-production',
|
||||
);
|
||||
return {
|
||||
secret: secret,
|
||||
signOptions: {
|
||||
expiresIn: expiresIn || '7d', // 默认7天过期
|
||||
},
|
||||
};
|
||||
},
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { User } from '../users/user.entity';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||
import { LoginResponse } from './interfaces/login-response.interface';
|
||||
import { UserStatus } from '../users/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
async login(loginDto: LoginDto, ip?: string): Promise<LoginResponse> {
|
||||
// 根据用户名或邮箱查找用户
|
||||
const user = await this.userRepository.findOne({
|
||||
where: [{ username: loginDto.usernameOrEmail }, { email: loginDto.usernameOrEmail }],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('用户名或密码错误');
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if (user.status !== UserStatus.ACTIVE) {
|
||||
throw new UnauthorizedException('用户已被禁用');
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isPasswordValid = await bcrypt.compare(loginDto.password, user.passwordHash);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('用户名或密码错误');
|
||||
}
|
||||
|
||||
// 更新最后登录时间和IP
|
||||
user.lastLoginAt = new Date();
|
||||
if (ip) {
|
||||
user.lastLoginIp = ip;
|
||||
}
|
||||
await this.userRepository.save(user);
|
||||
|
||||
// 生成 JWT token
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
};
|
||||
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
|
||||
// 返回 token 和用户信息(排除密码)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { passwordHash, ...userWithoutPassword } = user;
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
user: userWithoutPassword,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* 角色权限装饰器
|
||||
*
|
||||
* 这个装饰器用于在控制器或方法上标记需要的角色权限。
|
||||
* 配合 RolesGuard 使用,实现基于角色的访问控制(RBAC)。
|
||||
*
|
||||
* 使用示例:
|
||||
* @Roles('admin', 'super_admin')
|
||||
* @Get()
|
||||
* findAll() { ... }
|
||||
*/
|
||||
|
||||
/**
|
||||
* 元数据键名
|
||||
* 用于在 Reflector 中存储和读取角色信息
|
||||
*/
|
||||
export const ROLES_KEY = 'roles';
|
||||
|
||||
/**
|
||||
* 角色权限装饰器工厂函数
|
||||
*
|
||||
* @param roles - 允许访问的角色列表(可变参数)
|
||||
* @returns 返回一个装饰器函数,用于设置元数据
|
||||
*/
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IsString, IsNotEmpty, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({
|
||||
description: '用户名或邮箱',
|
||||
example: 'admin',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(3)
|
||||
usernameOrEmail: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '密码',
|
||||
example: 'password123',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
handleRequest<TUser = unknown>(
|
||||
err: Error | null,
|
||||
user: TUser | false,
|
||||
info: Error | string | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_context: ExecutionContext,
|
||||
): TUser {
|
||||
// 如果认证失败(user 为 false 或 undefined,或者有错误)
|
||||
if (err || !user || info) {
|
||||
throw new UnauthorizedException('身份验证失败');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { User, UserRole } from '../../users/user.entity';
|
||||
|
||||
/**
|
||||
* 资源所有者或管理员权限 Guard
|
||||
*
|
||||
* 用途:检查用户是否有权限访问资源
|
||||
* - 管理员(admin、super_admin)可以访问任何资源
|
||||
* - 普通用户只能访问自己的资源(通过比较 id)
|
||||
*
|
||||
* 使用场景:
|
||||
* - 查询用户信息:管理员可以查询任何用户,普通用户只能查询自己
|
||||
* - 更新用户信息:管理员可以更新任何用户,普通用户只能更新自己
|
||||
* - 删除用户:管理员可以删除任何用户,普通用户只能删除自己
|
||||
*/
|
||||
@Injectable()
|
||||
export class OwnerOrAdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
user?: User;
|
||||
params: { id?: string };
|
||||
}>();
|
||||
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
throw new ForbiddenException('未授权访问');
|
||||
}
|
||||
|
||||
// 获取请求的资源ID(从路由参数中)
|
||||
const requestedId = request.params?.id;
|
||||
|
||||
if (!requestedId) {
|
||||
// 如果没有提供资源ID,只允许管理员访问
|
||||
const isAdmin = user.role === UserRole.ADMIN || user.role === UserRole.SUPER_ADMIN;
|
||||
if (!isAdmin) {
|
||||
throw new ForbiddenException('权限不足,需要管理员权限');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestedUserId = +requestedId;
|
||||
|
||||
// 检查权限:管理员可以访问任何资源,普通用户只能访问自己的资源
|
||||
const isAdmin = user.role === UserRole.ADMIN || user.role === UserRole.SUPER_ADMIN;
|
||||
const isOwner = user.id === requestedUserId;
|
||||
|
||||
if (!isAdmin && !isOwner) {
|
||||
throw new ForbiddenException('权限不足,只能访问自己的资源或需要管理员权限');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles) {
|
||||
// 如果没有设置角色要求,允许访问
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
user?: { role: string };
|
||||
}>();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
throw new ForbiddenException('未授权访问');
|
||||
}
|
||||
|
||||
const hasRole = requiredRoles.some((role) => user.role === role);
|
||||
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException(`需要以下角色之一:${requiredRoles.join('、')}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface JwtPayload {
|
||||
sub: number; // 用户ID
|
||||
username: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { User } from '../../users/user.entity';
|
||||
|
||||
export interface LoginResponse {
|
||||
accessToken: string;
|
||||
user: Omit<User, 'passwordHash'>; // 排除密码哈希
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User, UserStatus } from '../../users/user.entity';
|
||||
import { JwtPayload } from '../interfaces/jwt-payload.interface';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>(
|
||||
'JWT_SECRET',
|
||||
'your-secret-key-change-in-production',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<User> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user || user.status !== UserStatus.ACTIVE) {
|
||||
throw new UnauthorizedException('用户不存在或已被禁用');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
# 用户管理模块
|
||||
|
||||
## 功能说明
|
||||
|
||||
用户管理模块提供了完整的用户管理功能,包括用户创建、查询、更新、删除和密码修改。
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
### JWT 配置
|
||||
|
||||
```env
|
||||
JWT_SECRET=your-secret-key-change-in-production
|
||||
JWT_EXPIRES_IN=7d
|
||||
```
|
||||
|
||||
### 用户种子数据配置
|
||||
|
||||
在 `.env.development` 中配置(开发环境自动创建):
|
||||
|
||||
```env
|
||||
ENABLE_USER_SEEDER=true
|
||||
SUPER_ADMIN_USERNAME=superadmin
|
||||
SUPER_ADMIN_PASSWORD=admin123
|
||||
SUPER_ADMIN_EMAIL=superadmin@doodle.com
|
||||
SUPER_ADMIN_REAL_NAME=超级管理员
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin123
|
||||
ADMIN_EMAIL=admin@doodle.com
|
||||
ADMIN_REAL_NAME=系统管理员
|
||||
```
|
||||
|
||||
**注意:**
|
||||
- 生产环境建议设置 `ENABLE_USER_SEEDER=false`,手动创建管理员用户
|
||||
- 默认密码请在生产环境部署后立即修改
|
||||
|
||||
## API 接口
|
||||
|
||||
### 认证接口
|
||||
|
||||
- `POST /auth/login` - 用户登录(返回 JWT token)
|
||||
|
||||
### 用户管理接口
|
||||
|
||||
- `POST /users` - 创建用户(需要超级管理员权限)
|
||||
- `GET /users` - 查询用户列表(需要管理员权限,支持分页和筛选)
|
||||
- `GET /users/:id` - 查询单个用户(需要管理员权限或用户本人)
|
||||
- `PATCH /users/:id` - 更新用户信息(需要管理员权限或用户本人)
|
||||
- `PATCH /users/:id/password` - 修改密码(需要管理员权限或用户本人)
|
||||
- `DELETE /users/:id` - 删除用户(需要超级管理员权限,软删除)
|
||||
|
||||
## 权限说明
|
||||
|
||||
- **super_admin(超级管理员)**:所有权限
|
||||
- **admin(管理员)**:可以管理用户,但不能创建/删除用户
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 登录
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3301/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"usernameOrEmail": "admin",
|
||||
"password": "admin123"
|
||||
}'
|
||||
```
|
||||
|
||||
### 查询用户列表(需要 JWT token)
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:3301/users \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
### 创建用户(需要超级管理员权限)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3301/users \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "newuser",
|
||||
"password": "password123",
|
||||
"email": "newuser@example.com",
|
||||
"role": "admin",
|
||||
"realName": "新用户"
|
||||
}'
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IsString, IsNotEmpty, MinLength, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty({
|
||||
description: '旧密码',
|
||||
example: 'OldPassword123!',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
oldPassword: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '新密码',
|
||||
example: 'NewPassword123!',
|
||||
minLength: 6,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(6)
|
||||
@MaxLength(100)
|
||||
newPassword: string;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsEmail,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
IsEnum,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { UserRole } from '../user.entity';
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiProperty({
|
||||
description: '用户名',
|
||||
example: 'admin',
|
||||
maxLength: 50,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(3)
|
||||
@MaxLength(50)
|
||||
username: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '密码(明文,服务端会使用 bcrypt 加密后存储)',
|
||||
example: 'SecurePassword123!',
|
||||
minLength: 6,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(6)
|
||||
@MaxLength(100)
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '邮箱',
|
||||
example: 'admin@example.com',
|
||||
maxLength: 100,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(100)
|
||||
email?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '用户角色',
|
||||
enum: UserRole,
|
||||
example: UserRole.ADMIN,
|
||||
})
|
||||
@IsEnum(UserRole)
|
||||
@IsNotEmpty()
|
||||
role: UserRole;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '真实姓名',
|
||||
example: '张三',
|
||||
maxLength: 50,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
realName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '头像URL',
|
||||
example: 'https://example.com/avatar.jpg',
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
avatar?: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { User } from '../user.entity';
|
||||
|
||||
export class PaginationInfo {
|
||||
@ApiProperty({ description: '总记录数', example: 100 })
|
||||
total: number;
|
||||
|
||||
@ApiProperty({ description: '总页数', example: 10 })
|
||||
total_page: number;
|
||||
|
||||
@ApiProperty({ description: '每页数量', example: 10 })
|
||||
page_size: number;
|
||||
|
||||
@ApiProperty({ description: '当前页码', example: 1 })
|
||||
current_page: number;
|
||||
}
|
||||
|
||||
export class PaginatedUserData {
|
||||
@ApiProperty({ description: '用户列表', type: [User] })
|
||||
list: Omit<User, 'passwordHash'>[];
|
||||
|
||||
@ApiProperty({ description: '分页信息', type: PaginationInfo })
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { IsOptional, IsString, IsNumber, Min, IsEnum } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { UserRole, UserStatus } from '../user.entity';
|
||||
|
||||
export class QueryUserDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '用户名',
|
||||
example: 'admin',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '邮箱',
|
||||
example: 'admin@example.com',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '角色',
|
||||
enum: UserRole,
|
||||
example: UserRole.ADMIN,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(UserRole)
|
||||
role?: UserRole;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '状态',
|
||||
enum: UserStatus,
|
||||
example: UserStatus.ACTIVE,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(UserStatus)
|
||||
status?: UserStatus;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '页码',
|
||||
example: 1,
|
||||
minimum: 1,
|
||||
default: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '每页数量',
|
||||
example: 10,
|
||||
minimum: 1,
|
||||
default: 10,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
limit?: number = 10;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { IsString, IsOptional, IsEmail, MaxLength, IsEnum } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { UserRole, UserStatus } from '../user.entity';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '邮箱',
|
||||
example: 'newemail@example.com',
|
||||
maxLength: 100,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(100)
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '真实姓名',
|
||||
example: '张三',
|
||||
maxLength: 50,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
realName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '头像URL',
|
||||
example: 'https://example.com/avatar.jpg',
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
avatar?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '用户状态',
|
||||
enum: UserStatus,
|
||||
example: UserStatus.ACTIVE,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(UserStatus)
|
||||
status?: UserStatus;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '用户角色',
|
||||
enum: UserRole,
|
||||
example: UserRole.ADMIN,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(UserRole)
|
||||
role?: UserRole;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export enum UserRole {
|
||||
SUPER_ADMIN = 'super_admin',
|
||||
ADMIN = 'admin',
|
||||
}
|
||||
|
||||
export enum UserStatus {
|
||||
ACTIVE = 'active',
|
||||
INACTIVE = 'inactive',
|
||||
}
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@ApiProperty({ description: '用户ID', example: 1 })
|
||||
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||
id: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '用户名',
|
||||
example: 'admin',
|
||||
maxLength: 50,
|
||||
})
|
||||
@Column({ type: 'varchar', length: 50, unique: true })
|
||||
@Index()
|
||||
username: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '邮箱',
|
||||
example: 'admin@example.com',
|
||||
maxLength: 100,
|
||||
})
|
||||
@Column({ type: 'varchar', length: 100, unique: true, nullable: true })
|
||||
@Index()
|
||||
email: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '密码哈希值(bcrypt加密)',
|
||||
example: '$2b$10$...',
|
||||
maxLength: 255,
|
||||
})
|
||||
@Column({ type: 'varchar', length: 255, name: 'password_hash' })
|
||||
passwordHash: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '用户角色',
|
||||
example: 'admin',
|
||||
enum: UserRole,
|
||||
default: UserRole.ADMIN,
|
||||
})
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: UserRole.ADMIN,
|
||||
nullable: false,
|
||||
})
|
||||
@Index()
|
||||
role: UserRole;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '真实姓名',
|
||||
example: '张三',
|
||||
maxLength: 50,
|
||||
})
|
||||
@Column({ type: 'varchar', length: 50, nullable: true, name: 'real_name' })
|
||||
realName: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '头像URL',
|
||||
example: 'https://example.com/avatar.jpg',
|
||||
maxLength: 500,
|
||||
})
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
avatar: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '用户状态',
|
||||
example: 'active',
|
||||
enum: UserStatus,
|
||||
default: UserStatus.ACTIVE,
|
||||
})
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: UserStatus.ACTIVE,
|
||||
nullable: false,
|
||||
})
|
||||
@Index()
|
||||
status: UserStatus;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '最后登录时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
@Column({ type: 'timestamp', nullable: true, name: 'last_login_at' })
|
||||
lastLoginAt: Date | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '最后登录IP',
|
||||
example: '192.168.1.1',
|
||||
maxLength: 50,
|
||||
})
|
||||
@Column({ type: 'varchar', length: 50, nullable: true, name: 'last_login_ip' })
|
||||
lastLoginIp: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '创建时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: '更新时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { UsersService } from './users.service';
|
||||
import { User } from './user.entity';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { QueryUserDto } from './dto/query-user.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { OwnerOrAdminGuard } from '../auth/guards/owner-or-admin.guard';
|
||||
import { PaginatedUserData } from './dto/paginated-response.dto';
|
||||
|
||||
@ApiTags('users')
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
*/
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('super_admin')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: '创建用户',
|
||||
description: '创建新用户,需要超级管理员权限',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: '创建成功',
|
||||
type: User,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
@ApiResponse({
|
||||
status: 409,
|
||||
description: '用户名或邮箱已存在',
|
||||
})
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
create(@Body() createUserDto: CreateUserDto): Promise<User> {
|
||||
return this.usersService.create(createUserDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询所有用户(支持分页和筛选)
|
||||
*/
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '查询用户列表',
|
||||
description: '获取用户列表,支持分页和多种筛选条件(需要管理员权限)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
type: PaginatedUserData,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
findAll(@Query() queryDto: QueryUserDto): Promise<PaginatedUserData> {
|
||||
return this.usersService.findAllPaginated(queryDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 查询单个用户
|
||||
* 需要管理员权限或者是用户本人
|
||||
*/
|
||||
@Get(':id')
|
||||
@UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '根据ID查询用户',
|
||||
description: '根据用户ID获取详细信息,需要管理员权限或者是用户本人',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '用户ID', type: Number })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
type: User,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({
|
||||
status: 403,
|
||||
description: '权限不足,只能查询自己的信息或需要管理员权限',
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||
findOneById(@Param('id', ParseIntPipe) id: number): Promise<User> {
|
||||
return this.usersService.findOneById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
*/
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '更新用户信息',
|
||||
description: '更新用户信息,不允许修改 username',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '用户ID', type: Number })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '更新成功',
|
||||
type: User,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||
@ApiResponse({ status: 409, description: '邮箱已存在' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() updateUserDto: UpdateUserDto,
|
||||
): Promise<User> {
|
||||
return this.usersService.update(id, updateUserDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
@Patch(':id/password')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@UseGuards(JwtAuthGuard, OwnerOrAdminGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '修改密码',
|
||||
description: '修改用户密码,需要先验证旧密码',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '用户ID', type: Number })
|
||||
@ApiResponse({ status: 204, description: '密码修改成功' })
|
||||
@ApiResponse({ status: 400, description: '旧密码错误' })
|
||||
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||
changePassword(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() changePasswordDto: ChangePasswordDto,
|
||||
): Promise<void> {
|
||||
return this.usersService.changePassword(id, changePasswordDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户(软删除)
|
||||
*/
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '删除用户',
|
||||
description: '软删除用户,将状态更新为 inactive(需要超级管理员权限)',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '用户ID', type: Number })
|
||||
@ApiResponse({ status: 204, description: '删除成功' })
|
||||
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||
return this.usersService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UsersService } from './users.service';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersSeeder } from './users.seeder';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService, UsersSeeder],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In } from 'typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { User, UserRole, UserStatus } from './user.entity';
|
||||
|
||||
/**
|
||||
* 用户数据种子(Seeder)
|
||||
*
|
||||
* 用途:在应用启动时自动创建初始用户(超级管理员、管理员)
|
||||
*
|
||||
* 功能:
|
||||
* 1. 创建超级管理员和管理员各一名(从环境变量读取配置)
|
||||
*
|
||||
* 性能优化:
|
||||
* - 使用批量查询检查用户是否存在
|
||||
* - 只创建不存在的用户,保证幂等性
|
||||
*
|
||||
* 优点:
|
||||
* 1. 代码化管理,版本控制友好
|
||||
* 2. 自动执行,无需手动操作
|
||||
* 3. 可以使用业务逻辑(密码加密、验证等)
|
||||
* 4. 环境变量配置,灵活性强
|
||||
* 5. 幂等性:如果用户已存在,不会重复创建
|
||||
*
|
||||
* 使用方式:
|
||||
* 1. 通过环境变量配置管理员信息
|
||||
* 2. 应用启动时自动执行
|
||||
* 3. 仅在开发/测试环境自动执行,生产环境建议手动创建
|
||||
*/
|
||||
@Injectable()
|
||||
export class UsersSeeder implements OnModuleInit {
|
||||
private readonly logger = new Logger(UsersSeeder.name);
|
||||
private readonly saltRounds = 10; // bcrypt 加盐轮数
|
||||
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
// 只在非生产环境自动执行,或通过环境变量控制
|
||||
const isProduction = this.configService.get('NODE_ENV') === 'production';
|
||||
const enableSeeder = this.configService.get('ENABLE_USER_SEEDER', 'false') === 'true';
|
||||
|
||||
if (isProduction && !enableSeeder) {
|
||||
this.logger.log('生产环境跳过用户种子数据初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
// 执行种子数据创建
|
||||
await this.seedAdminUsers();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建管理员用户(超级管理员和管理员)
|
||||
*/
|
||||
async seedAdminUsers(): Promise<void> {
|
||||
try {
|
||||
// 从环境变量读取超级管理员配置
|
||||
const superAdminUsername =
|
||||
this.configService.get<string>('SUPER_ADMIN_USERNAME') || 'superadmin';
|
||||
const superAdminPassword =
|
||||
this.configService.get<string>('SUPER_ADMIN_PASSWORD') || 'admin123';
|
||||
const superAdminEmail =
|
||||
this.configService.get<string>('SUPER_ADMIN_EMAIL') || 'superadmin@doodle.com';
|
||||
const superAdminRealName =
|
||||
this.configService.get<string>('SUPER_ADMIN_REAL_NAME') || '超级管理员';
|
||||
|
||||
// 从环境变量读取管理员配置
|
||||
const adminUsername = this.configService.get<string>('ADMIN_USERNAME') || 'admin';
|
||||
const adminPassword = this.configService.get<string>('ADMIN_PASSWORD') || 'admin123';
|
||||
const adminEmail = this.configService.get<string>('ADMIN_EMAIL') || 'admin@doodle.com';
|
||||
const adminRealName = this.configService.get<string>('ADMIN_REAL_NAME') || '系统管理员';
|
||||
|
||||
// 构建管理员用户数据
|
||||
const adminUsersToCreate = [
|
||||
{
|
||||
username: superAdminUsername,
|
||||
email: superAdminEmail,
|
||||
realName: superAdminRealName,
|
||||
password: superAdminPassword,
|
||||
role: UserRole.SUPER_ADMIN,
|
||||
},
|
||||
{
|
||||
username: adminUsername,
|
||||
email: adminEmail,
|
||||
realName: adminRealName,
|
||||
password: adminPassword,
|
||||
role: UserRole.ADMIN,
|
||||
},
|
||||
];
|
||||
|
||||
// 批量查询:一次检查所有管理员用户是否存在(性能优化)
|
||||
const usernames = adminUsersToCreate.map((u) => u.username);
|
||||
const existingAdminUsers = await this.userRepository.find({
|
||||
where: { username: In(usernames) },
|
||||
select: ['username'],
|
||||
});
|
||||
|
||||
const existingUsernamesSet = new Set(existingAdminUsers.map((u) => u.username));
|
||||
|
||||
// 过滤出需要创建的用户(不存在的用户)
|
||||
const usersToCreate = adminUsersToCreate.filter(
|
||||
(user) => !existingUsernamesSet.has(user.username),
|
||||
);
|
||||
|
||||
if (usersToCreate.length === 0) {
|
||||
this.logger.log(
|
||||
`管理员用户已存在(超级管理员: ${superAdminUsername}, 管理员: ${adminUsername}),跳过创建`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 批量创建用户
|
||||
const usersToSave = await Promise.all(
|
||||
usersToCreate.map(async (userData) => {
|
||||
const passwordHash = await bcrypt.hash(userData.password, this.saltRounds);
|
||||
|
||||
return this.userRepository.create({
|
||||
username: userData.username,
|
||||
passwordHash,
|
||||
email: userData.email,
|
||||
realName: userData.realName,
|
||||
role: userData.role,
|
||||
status: UserStatus.ACTIVE,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await this.userRepository.save(usersToSave);
|
||||
|
||||
// 记录日志
|
||||
const createdUsernames = usersToCreate.map((u) => u.username);
|
||||
this.logger.log(`✅ 成功创建管理员用户: ${createdUsernames.join(', ')}`);
|
||||
usersToCreate.forEach((user) => {
|
||||
this.logger.warn(
|
||||
`⚠️ ${user.role === UserRole.SUPER_ADMIN ? '超级管理员' : '管理员'} "${user.username}" 默认密码: ${user.password},请尽快修改!`,
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('创建管理员用户失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动执行种子数据(可用于 CLI 命令)
|
||||
*/
|
||||
async run(): Promise<void> {
|
||||
await this.seedAdminUsers();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, FindOptionsWhere } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { User, UserStatus } from './user.entity';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { QueryUserDto } from './dto/query-user.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { PaginatedUserData } from './dto/paginated-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
private readonly logger = new Logger(UsersService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
*/
|
||||
async create(createUserDto: CreateUserDto): Promise<User> {
|
||||
// 检查用户名是否已存在
|
||||
const existingByUsername = await this.userRepository.findOne({
|
||||
where: { username: createUserDto.username },
|
||||
});
|
||||
|
||||
if (existingByUsername) {
|
||||
throw new ConflictException(`用户名 "${createUserDto.username}" 已存在`);
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在(如果提供了)
|
||||
if (createUserDto.email) {
|
||||
const existingByEmail = await this.userRepository.findOne({
|
||||
where: { email: createUserDto.email },
|
||||
});
|
||||
|
||||
if (existingByEmail) {
|
||||
throw new ConflictException(`邮箱 "${createUserDto.email}" 已存在`);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 bcrypt 加密密码
|
||||
const saltRounds = 10;
|
||||
const passwordHash = await bcrypt.hash(createUserDto.password, saltRounds);
|
||||
|
||||
// 创建用户
|
||||
const user = this.userRepository.create({
|
||||
username: createUserDto.username,
|
||||
passwordHash,
|
||||
email: createUserDto.email || null,
|
||||
role: createUserDto.role,
|
||||
realName: createUserDto.realName || null,
|
||||
avatar: createUserDto.avatar || null,
|
||||
status: UserStatus.ACTIVE,
|
||||
});
|
||||
|
||||
const savedUser = await this.userRepository.save(user);
|
||||
|
||||
// 移除 passwordHash 字段
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { passwordHash: _, ...userWithoutPassword } = savedUser;
|
||||
return userWithoutPassword as User;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户(支持多种查询条件和分页)
|
||||
*/
|
||||
async findAllPaginated(queryDto: QueryUserDto): Promise<PaginatedUserData> {
|
||||
const where: FindOptionsWhere<User> = {};
|
||||
|
||||
if (queryDto.username) {
|
||||
where.username = queryDto.username;
|
||||
}
|
||||
|
||||
if (queryDto.email) {
|
||||
where.email = queryDto.email;
|
||||
}
|
||||
|
||||
if (queryDto.role) {
|
||||
where.role = queryDto.role;
|
||||
}
|
||||
|
||||
if (queryDto.status) {
|
||||
where.status = queryDto.status;
|
||||
}
|
||||
|
||||
// 分页参数
|
||||
const page = queryDto.page || 1;
|
||||
const limit = queryDto.limit || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// 查询总数
|
||||
const total = await this.userRepository.count({ where });
|
||||
|
||||
// 查询分页数据
|
||||
const list = await this.userRepository.find({
|
||||
where,
|
||||
order: {
|
||||
createdAt: 'DESC',
|
||||
id: 'ASC',
|
||||
},
|
||||
skip,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
// 移除 passwordHash 字段
|
||||
const listWithoutPassword = list.map((user) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { passwordHash, ...userWithoutPassword } = user;
|
||||
return userWithoutPassword;
|
||||
});
|
||||
|
||||
// 计算总页数
|
||||
const total_page = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
list: listWithoutPassword,
|
||||
pagination: {
|
||||
total,
|
||||
total_page,
|
||||
page_size: limit,
|
||||
current_page: page,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 查询单个用户
|
||||
*/
|
||||
async findOneById(id: number): Promise<User> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException(`未找到ID为 ${id} 的用户`);
|
||||
}
|
||||
|
||||
// 移除 passwordHash 字段
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { passwordHash, ...userWithoutPassword } = user;
|
||||
return userWithoutPassword as User;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户信息(不允许修改 username)
|
||||
*/
|
||||
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException(`未找到ID为 ${id} 的用户`);
|
||||
}
|
||||
|
||||
// 如果更新邮箱,检查是否与其他用户冲突
|
||||
if (updateUserDto.email && updateUserDto.email !== user.email) {
|
||||
const existingByEmail = await this.userRepository.findOne({
|
||||
where: { email: updateUserDto.email },
|
||||
});
|
||||
|
||||
if (existingByEmail) {
|
||||
throw new ConflictException(`邮箱 "${updateUserDto.email}" 已存在`);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
Object.assign(user, updateUserDto);
|
||||
const savedUser = await this.userRepository.save(user);
|
||||
|
||||
// 移除 passwordHash 字段
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { passwordHash, ...userWithoutPassword } = savedUser;
|
||||
return userWithoutPassword as User;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码(需要先验证旧密码)
|
||||
*/
|
||||
async changePassword(id: number, changePasswordDto: ChangePasswordDto): Promise<void> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException(`未找到ID为 ${id} 的用户`);
|
||||
}
|
||||
|
||||
// 验证旧密码
|
||||
const isOldPasswordValid = await bcrypt.compare(
|
||||
changePasswordDto.oldPassword,
|
||||
user.passwordHash,
|
||||
);
|
||||
|
||||
if (!isOldPasswordValid) {
|
||||
throw new BadRequestException('旧密码错误');
|
||||
}
|
||||
|
||||
// 加密新密码
|
||||
const saltRounds = 10;
|
||||
const newPasswordHash = await bcrypt.hash(changePasswordDto.newPassword, saltRounds);
|
||||
|
||||
// 更新密码
|
||||
user.passwordHash = newPasswordHash;
|
||||
await this.userRepository.save(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户(软删除,更新状态为 inactive)
|
||||
*/
|
||||
async remove(id: number): Promise<void> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException(`未找到ID为 ${id} 的用户`);
|
||||
}
|
||||
|
||||
user.status = UserStatus.INACTIVE;
|
||||
await this.userRepository.save(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { IsString, IsOptional, IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreateSentenceDto {
|
||||
@ApiProperty({ description: '句子内容', example: '这是一个例句。' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
content: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '翻译(英语句子需要)',
|
||||
example: 'This is an example sentence.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
translation?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '音频文件路径',
|
||||
example: '/audio/sentence1.mp3',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
audioPath?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '来源(如教材名称)',
|
||||
example: '人教版语文一年级上册',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
source?: string;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { IsString, IsOptional, IsInt, IsArray, Min, Max, IsEnum, IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { WordType } from '../word.entity';
|
||||
|
||||
export class CreateWordDto {
|
||||
@ApiProperty({ description: '字词内容', example: '中' })
|
||||
@IsString()
|
||||
content: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '字词类型',
|
||||
enum: WordType,
|
||||
example: WordType.CHINESE_CHAR,
|
||||
})
|
||||
@IsEnum(WordType)
|
||||
@IsNotEmpty()
|
||||
type: WordType;
|
||||
|
||||
@ApiProperty({
|
||||
description: '年级(1-9)',
|
||||
required: false,
|
||||
minimum: 1,
|
||||
maximum: 9,
|
||||
example: 1,
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(9)
|
||||
grade?: number | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '拼音数组(支持多音字)',
|
||||
required: false,
|
||||
type: [String],
|
||||
example: ['zhōng', 'zhòng'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
pinyins?: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '读音数组',
|
||||
required: false,
|
||||
type: [String],
|
||||
example: ['audio1.mp3', 'audio2.mp3'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
pronunciations?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'SVG笔画数据(字符串数组,每个元素是一个SVG字符串)',
|
||||
type: [String],
|
||||
example: ['<svg>...</svg>', '<svg>...</svg>'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
svgData?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '音频文件地址数组',
|
||||
type: [String],
|
||||
example: ['/audio/word1.mp3', '/audio/word1_alt.mp3'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
audioFiles?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '描述信息',
|
||||
example: '这是一个汉字',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Word } from '../word.entity';
|
||||
|
||||
export class PaginationInfo {
|
||||
@ApiProperty({ description: '总记录数', example: 100 })
|
||||
total: number;
|
||||
|
||||
@ApiProperty({ description: '总页数', example: 10 })
|
||||
total_page: number;
|
||||
|
||||
@ApiProperty({ description: '每页数量', example: 9 })
|
||||
page_size: number;
|
||||
|
||||
@ApiProperty({ description: '当前页码', example: 1 })
|
||||
current_page: number;
|
||||
}
|
||||
|
||||
export class PaginatedWordData {
|
||||
@ApiProperty({ description: '汉字列表', type: [Word] })
|
||||
list: Word[];
|
||||
|
||||
@ApiProperty({ description: '分页信息', type: PaginationInfo })
|
||||
pagination: PaginationInfo;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { IsOptional, IsString, IsInt, IsEnum, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { WordType } from '../word.entity';
|
||||
|
||||
export class QueryWordsDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '搜索关键词(汉字内容)',
|
||||
example: '中',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '字词类型',
|
||||
enum: WordType,
|
||||
example: WordType.CHINESE_CHAR,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(WordType)
|
||||
type?: WordType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '年级(1-9)',
|
||||
minimum: 1,
|
||||
maximum: 9,
|
||||
example: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(9)
|
||||
grade?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '页码',
|
||||
minimum: 1,
|
||||
default: 1,
|
||||
example: 1,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '每页数量',
|
||||
minimum: 1,
|
||||
default: 9,
|
||||
example: 9,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
limit?: number = 9;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { IsArray, IsInt, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class WordImageItemDto {
|
||||
@ApiProperty({ description: '图片ID', example: 1 })
|
||||
@IsInt()
|
||||
id: number;
|
||||
|
||||
@ApiProperty({ description: '排序顺序', example: 0 })
|
||||
@IsInt()
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export class UpdateWordImagesDto {
|
||||
@ApiProperty({
|
||||
description: '图片列表(按新顺序)',
|
||||
type: [WordImageItemDto],
|
||||
})
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WordImageItemDto)
|
||||
images: WordImageItemDto[];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { CreateWordDto } from './create-word.dto';
|
||||
import { IsOptional, IsArray, IsInt } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class UpdateWordDto extends PartialType(CreateWordDto) {
|
||||
@ApiPropertyOptional({
|
||||
description: '关联词语ID数组',
|
||||
type: [Number],
|
||||
example: [1, 2, 3],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Type(() => Number)
|
||||
relatedWordIds?: number[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '关联句子ID数组',
|
||||
type: [Number],
|
||||
example: [1, 2, 3],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Type(() => Number)
|
||||
sentenceIds?: number[];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './word.entity';
|
||||
export * from './word-image.entity';
|
||||
export * from './sentence.entity';
|
||||
export * from './word-sentence.entity';
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { WordSentence } from './word-sentence.entity.js';
|
||||
|
||||
@Entity('sentences')
|
||||
export class Sentence {
|
||||
@ApiProperty({ description: '句子ID', example: 1 })
|
||||
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||
id: number;
|
||||
|
||||
@ApiProperty({ description: '句子内容', example: '这是一个例句。' })
|
||||
@Column({ type: 'text' })
|
||||
@Index()
|
||||
content: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '翻译(英语句子需要)',
|
||||
example: 'This is an example sentence.',
|
||||
nullable: true,
|
||||
})
|
||||
@Column({ type: 'text', nullable: true })
|
||||
translation: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '音频文件路径',
|
||||
example: '/audio/sentence1.mp3',
|
||||
maxLength: 500,
|
||||
nullable: true,
|
||||
})
|
||||
@Column({ type: 'varchar', length: 500, nullable: true, name: 'audio_path' })
|
||||
audioPath: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '来源(如教材名称)',
|
||||
example: '人教版语文一年级上册',
|
||||
maxLength: 100,
|
||||
nullable: true,
|
||||
})
|
||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||
source: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '创建时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: '更新时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
@OneToMany(() => WordSentence, (wordSentence) => wordSentence.sentence, {
|
||||
cascade: true,
|
||||
})
|
||||
wordSentences: WordSentence[];
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Word } from './word.entity';
|
||||
|
||||
export enum ImageType {
|
||||
ORIGINAL = 'original',
|
||||
STANDARD = 'standard',
|
||||
}
|
||||
|
||||
@Entity('word_images')
|
||||
export class WordImage {
|
||||
@ApiProperty({ description: '图片ID', example: 1 })
|
||||
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||
id: number;
|
||||
|
||||
@ApiProperty({ description: '字词ID', example: 1 })
|
||||
@Column({ type: 'bigint', name: 'word_id' })
|
||||
@Index()
|
||||
wordId: number;
|
||||
|
||||
@ManyToOne(() => Word, (word) => word.images, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'word_id' })
|
||||
word: Word;
|
||||
|
||||
@ApiProperty({
|
||||
description: '图片类型',
|
||||
enum: ImageType,
|
||||
example: ImageType.ORIGINAL,
|
||||
})
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ImageType,
|
||||
default: ImageType.ORIGINAL,
|
||||
name: 'image_type',
|
||||
})
|
||||
imageType: ImageType;
|
||||
|
||||
@ApiProperty({ description: '图片文件路径', example: '/images/word1.jpg', maxLength: 500 })
|
||||
@Column({ type: 'varchar', length: 500, name: 'file_path' })
|
||||
filePath: string;
|
||||
|
||||
@ApiProperty({ description: '原始文件名', example: 'word1.jpg', maxLength: 200 })
|
||||
@Column({ type: 'varchar', length: 200, name: 'file_name' })
|
||||
fileName: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '文件大小(字节)',
|
||||
example: 102400,
|
||||
nullable: true,
|
||||
})
|
||||
@Column({ type: 'bigint', nullable: true, name: 'file_size' })
|
||||
fileSize: number | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '图片宽度',
|
||||
example: 300,
|
||||
nullable: true,
|
||||
})
|
||||
@Column({ type: 'int', nullable: true })
|
||||
width: number | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '图片高度',
|
||||
example: 300,
|
||||
nullable: true,
|
||||
})
|
||||
@Column({ type: 'int', nullable: true })
|
||||
height: number | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'MIME类型',
|
||||
example: 'image/jpeg',
|
||||
maxLength: 50,
|
||||
nullable: true,
|
||||
})
|
||||
@Column({ type: 'varchar', length: 50, nullable: true, name: 'mime_type' })
|
||||
mimeType: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '是否为主图',
|
||||
example: false,
|
||||
default: false,
|
||||
})
|
||||
@Column({ type: 'boolean', default: false, name: 'is_primary' })
|
||||
isPrimary: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '排序顺序',
|
||||
example: 0,
|
||||
default: 0,
|
||||
})
|
||||
@Column({ type: 'int', default: 0, name: 'sort_order' })
|
||||
sortOrder: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '创建时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: '更新时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
Unique,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Word } from './word.entity';
|
||||
import { Sentence } from './sentence.entity';
|
||||
|
||||
@Entity('word_sentences')
|
||||
@Unique(['wordId', 'sentenceId'])
|
||||
export class WordSentence {
|
||||
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'bigint', name: 'word_id' })
|
||||
@Index()
|
||||
wordId: number;
|
||||
|
||||
@ManyToOne(() => Word, (word) => word.wordSentences, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'word_id' })
|
||||
word: Word;
|
||||
|
||||
@Column({ type: 'bigint', name: 'sentence_id' })
|
||||
@Index()
|
||||
sentenceId: number;
|
||||
|
||||
@ManyToOne(() => Sentence, (sentence) => sentence.wordSentences, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'sentence_id' })
|
||||
sentence: Sentence;
|
||||
|
||||
@Column({ type: 'int', default: 0, name: 'sort_order' })
|
||||
sortOrder: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
ManyToMany,
|
||||
JoinTable,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { WordImage } from './word-image.entity';
|
||||
import { WordSentence } from './word-sentence.entity';
|
||||
|
||||
export enum WordType {
|
||||
CHINESE_CHAR = 'chinese_char',
|
||||
CHINESE_WORD = 'chinese_word',
|
||||
ENGLISH_WORD = 'english_word',
|
||||
ENGLISH_LETTER = 'english_letter',
|
||||
}
|
||||
|
||||
export enum WordStatus {
|
||||
ACTIVE = 'active',
|
||||
INACTIVE = 'inactive',
|
||||
}
|
||||
|
||||
@Entity('words')
|
||||
export class Word {
|
||||
@ApiProperty({ description: '字词ID', example: 1 })
|
||||
@PrimaryGeneratedColumn({ type: 'bigint' })
|
||||
id: number;
|
||||
|
||||
@ApiProperty({ description: '字词内容', example: '中', maxLength: 50 })
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
@Index()
|
||||
content: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '字词类型',
|
||||
enum: WordType,
|
||||
example: WordType.CHINESE_CHAR,
|
||||
})
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: WordType,
|
||||
default: WordType.CHINESE_CHAR,
|
||||
})
|
||||
@Index()
|
||||
type: WordType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '年级(1-9)',
|
||||
example: 1,
|
||||
nullable: true,
|
||||
})
|
||||
@Column({ type: 'int', nullable: true })
|
||||
grade: number | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '拼音数组(支持多音字)',
|
||||
type: [String],
|
||||
example: ['zhōng', 'zhòng'],
|
||||
nullable: true,
|
||||
})
|
||||
@Column({ type: 'text', array: true, nullable: true })
|
||||
pinyins: string[] | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '读音数组',
|
||||
type: [String],
|
||||
example: ['audio1.mp3', 'audio2.mp3'],
|
||||
nullable: true,
|
||||
})
|
||||
@Column({ type: 'text', array: true, nullable: true })
|
||||
pronunciations: string[] | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'SVG笔画数据(字符串数组)',
|
||||
type: [String],
|
||||
example: ['<svg>...</svg>', '<svg>...</svg>'],
|
||||
nullable: true,
|
||||
})
|
||||
@Column({ type: 'text', array: true, nullable: true, name: 'svg_data' })
|
||||
svgData: string[] | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '音频文件地址数组',
|
||||
type: [String],
|
||||
example: ['/audio/word1.mp3', '/audio/word1_alt.mp3'],
|
||||
nullable: true,
|
||||
})
|
||||
@Column({ type: 'text', array: true, nullable: true, name: 'audio_files' })
|
||||
audioFiles: string[] | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '描述信息',
|
||||
example: '这是一个汉字',
|
||||
nullable: true,
|
||||
})
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '状态',
|
||||
enum: WordStatus,
|
||||
example: WordStatus.ACTIVE,
|
||||
default: WordStatus.ACTIVE,
|
||||
})
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: WordStatus,
|
||||
default: WordStatus.ACTIVE,
|
||||
})
|
||||
@Index()
|
||||
status: WordStatus;
|
||||
|
||||
@ApiProperty({
|
||||
description: '创建时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: '更新时间',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '创建人ID',
|
||||
nullable: true,
|
||||
})
|
||||
@Column({ type: 'bigint', nullable: true, name: 'created_by' })
|
||||
createdBy: number | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '更新人ID',
|
||||
nullable: true,
|
||||
})
|
||||
@Column({ type: 'bigint', nullable: true, name: 'updated_by' })
|
||||
updatedBy: number | null;
|
||||
|
||||
// 关联关系
|
||||
@OneToMany(() => WordImage, (image) => image.word, { cascade: true })
|
||||
images: WordImage[];
|
||||
|
||||
@OneToMany(() => WordSentence, (wordSentence) => wordSentence.word, {
|
||||
cascade: true,
|
||||
})
|
||||
wordSentences: WordSentence[];
|
||||
|
||||
// 关联词语(通过关联表)
|
||||
@ManyToMany(() => Word, (word) => word.id)
|
||||
@JoinTable({
|
||||
name: 'word_relations',
|
||||
joinColumn: { name: 'source_word_id', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'target_word_id', referencedColumnName: 'id' },
|
||||
})
|
||||
relatedWords: Word[];
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
} from '@nestjs/swagger';
|
||||
import { WordsService } from './words.service';
|
||||
import { Word } from './word.entity';
|
||||
import { CreateWordDto } from './dto/create-word.dto';
|
||||
import { UpdateWordDto } from './dto/update-word.dto';
|
||||
import { QueryWordsDto } from './dto/query-words.dto';
|
||||
import { CreateSentenceDto } from './dto/create-sentence.dto';
|
||||
import { PaginatedWordData } from './dto/paginated-response.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
|
||||
@ApiTags('words')
|
||||
@Controller('words')
|
||||
export class WordsController {
|
||||
constructor(private readonly wordsService: WordsService) {}
|
||||
|
||||
/**
|
||||
* 查询所有汉字(支持分页和筛选)
|
||||
*/
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '查询汉字列表',
|
||||
description: '获取汉字列表,支持分页和多种筛选条件(需要管理员权限)',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
type: PaginatedWordData,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
findAll(@Query() queryDto: QueryWordsDto): Promise<PaginatedWordData> {
|
||||
return this.wordsService.findAllPaginated(queryDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 查询单个汉字
|
||||
*/
|
||||
@Get(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '根据ID查询汉字',
|
||||
description: '根据汉字ID获取详细信息(需要管理员权限)',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
type: Word,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
@ApiResponse({ status: 404, description: '汉字不存在' })
|
||||
findOneById(@Param('id', ParseIntPipe) id: number): Promise<Word> {
|
||||
return this.wordsService.findOneById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建汉字
|
||||
*/
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: '创建汉字',
|
||||
description: '创建新的汉字记录(需要管理员权限)',
|
||||
})
|
||||
@ApiBody({ type: CreateWordDto })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: '创建成功',
|
||||
type: Word,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
create(@Body() createWordDto: CreateWordDto): Promise<Word> {
|
||||
return this.wordsService.create(createWordDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新汉字信息
|
||||
*/
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '更新汉字',
|
||||
description: '更新指定汉字的信息(需要管理员权限)',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
|
||||
@ApiBody({ type: UpdateWordDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '更新成功',
|
||||
type: Word,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '汉字不存在' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() updateWordDto: UpdateWordDto,
|
||||
): Promise<Word> {
|
||||
return this.wordsService.update(id, updateWordDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除汉字
|
||||
*/
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '删除汉字',
|
||||
description: '删除指定的汉字记录(需要管理员权限)',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
|
||||
@ApiResponse({ status: 204, description: '删除成功' })
|
||||
@ApiResponse({ status: 404, description: '汉字不存在' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||
return this.wordsService.remove(id);
|
||||
}
|
||||
|
||||
// 图片相关接口
|
||||
/**
|
||||
* 获取汉字图片列表
|
||||
*/
|
||||
@Get(':id/images')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '获取汉字图片列表',
|
||||
description: '获取指定汉字关联的所有图片(需要管理员权限)',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
|
||||
@ApiResponse({ status: 200, description: '成功返回图片列表' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
getWordImages(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.wordsService.getWordImages(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新图片排序
|
||||
*/
|
||||
@Patch(':id/images/sort')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '更新图片排序',
|
||||
description: '更新汉字图片的排序顺序(需要管理员权限)',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
imageIds: {
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
description: '图片ID数组,按新顺序排列',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功更新排序' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
updateWordImagesSort(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body('imageIds') imageIds: number[],
|
||||
) {
|
||||
return this.wordsService.updateWordImagesSort(id, imageIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除汉字图片
|
||||
*/
|
||||
@Delete(':id/images/:imageId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '删除汉字图片',
|
||||
description: '删除指定汉字关联的图片(需要管理员权限)',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
|
||||
@ApiParam({ name: 'imageId', description: '图片ID', type: Number })
|
||||
@ApiResponse({ status: 204, description: '删除成功' })
|
||||
@ApiResponse({ status: 404, description: '图片不存在' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
deleteWordImage(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Param('imageId', ParseIntPipe) imageId: number,
|
||||
): Promise<void> {
|
||||
return this.wordsService.deleteWordImage(id, imageId);
|
||||
}
|
||||
|
||||
// 句子相关接口
|
||||
/**
|
||||
* 获取汉字句子列表
|
||||
*/
|
||||
@Get(':id/sentences')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '获取汉字句子列表',
|
||||
description: '获取指定汉字关联的所有句子(需要管理员权限)',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
|
||||
@ApiResponse({ status: 200, description: '成功返回句子列表' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
getWordSentences(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.wordsService.getWordSentences(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加句子到汉字
|
||||
*/
|
||||
@Post(':id/sentences')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: '添加句子',
|
||||
description: '为指定汉字添加新的关联句子(需要管理员权限)',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
|
||||
@ApiBody({ type: CreateSentenceDto })
|
||||
@ApiResponse({ status: 201, description: '成功添加句子' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
addSentenceToWord(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() createSentenceDto: CreateSentenceDto,
|
||||
) {
|
||||
return this.wordsService.addSentenceToWord(id, createSentenceDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从汉字中删除句子
|
||||
*/
|
||||
@Delete(':id/sentences/:sentenceId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'super_admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '删除句子',
|
||||
description: '删除指定汉字关联的句子(需要管理员权限)',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '汉字ID', type: Number })
|
||||
@ApiParam({ name: 'sentenceId', description: '句子ID', type: Number })
|
||||
@ApiResponse({ status: 204, description: '删除成功' })
|
||||
@ApiResponse({ status: 404, description: '句子关联不存在' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
removeSentenceFromWord(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Param('sentenceId', ParseIntPipe) sentenceId: number,
|
||||
): Promise<void> {
|
||||
return this.wordsService.removeSentenceFromWord(id, sentenceId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { WordsService } from './words.service';
|
||||
import { WordsController } from './words.controller';
|
||||
import { Word } from './word.entity';
|
||||
import { WordImage } from './word-image.entity';
|
||||
import { Sentence } from './sentence.entity';
|
||||
import { WordSentence } from './word-sentence.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Word, WordImage, Sentence, WordSentence]),
|
||||
],
|
||||
controllers: [WordsController],
|
||||
providers: [WordsService],
|
||||
exports: [WordsService],
|
||||
})
|
||||
export class WordsModule {}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, FindOptionsWhere, In } from 'typeorm';
|
||||
import { Word, WordType, WordStatus } from './word.entity';
|
||||
import { WordImage } from './word-image.entity';
|
||||
import { Sentence } from './sentence.entity';
|
||||
import { WordSentence } from './word-sentence.entity';
|
||||
import { CreateWordDto } from './dto/create-word.dto';
|
||||
import { UpdateWordDto } from './dto/update-word.dto';
|
||||
import { QueryWordsDto } from './dto/query-words.dto';
|
||||
import { CreateSentenceDto } from './dto/create-sentence.dto';
|
||||
import { PaginatedWordData } from './dto/paginated-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class WordsService {
|
||||
private readonly logger = new Logger(WordsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Word)
|
||||
private readonly wordRepository: Repository<Word>,
|
||||
@InjectRepository(WordImage)
|
||||
private readonly wordImageRepository: Repository<WordImage>,
|
||||
@InjectRepository(Sentence)
|
||||
private readonly sentenceRepository: Repository<Sentence>,
|
||||
@InjectRepository(WordSentence)
|
||||
private readonly wordSentenceRepository: Repository<WordSentence>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建汉字
|
||||
*/
|
||||
async create(createWordDto: CreateWordDto): Promise<Word> {
|
||||
const word = this.wordRepository.create(createWordDto);
|
||||
return await this.wordRepository.save(word);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询汉字(支持多种查询条件和分页)
|
||||
*/
|
||||
async findAllPaginated(queryDto: QueryWordsDto): Promise<PaginatedWordData> {
|
||||
const where: FindOptionsWhere<Word> = {};
|
||||
|
||||
// 只查询汉字
|
||||
where.type = WordType.CHINESE_CHAR;
|
||||
|
||||
// 搜索条件
|
||||
if (queryDto.search) {
|
||||
// 使用 Like 进行模糊搜索
|
||||
const queryBuilder = this.wordRepository.createQueryBuilder('word');
|
||||
queryBuilder.where('word.type = :type', { type: WordType.CHINESE_CHAR });
|
||||
queryBuilder.andWhere('word.content LIKE :search', {
|
||||
search: `%${queryDto.search}%`,
|
||||
});
|
||||
|
||||
if (queryDto.grade) {
|
||||
queryBuilder.andWhere('word.grade = :grade', { grade: queryDto.grade });
|
||||
}
|
||||
|
||||
// 排序:按年级升序(低年级在前),年级相同时按创建时间
|
||||
queryBuilder.orderBy('word.grade', 'ASC', 'NULLS LAST');
|
||||
queryBuilder.addOrderBy('word.createdAt', 'ASC');
|
||||
queryBuilder.addOrderBy('word.id', 'ASC');
|
||||
|
||||
// 分页参数
|
||||
const page = queryDto.page || 1;
|
||||
const limit = queryDto.limit || 9;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// 加载关联数据
|
||||
queryBuilder.leftJoinAndSelect('word.images', 'images');
|
||||
queryBuilder.leftJoinAndSelect('word.wordSentences', 'wordSentences');
|
||||
queryBuilder.leftJoinAndSelect('wordSentences.sentence', 'sentence');
|
||||
|
||||
// 分页
|
||||
queryBuilder.skip(skip).take(limit);
|
||||
|
||||
const [list, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
// 计算总页数
|
||||
const total_page = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
list,
|
||||
pagination: {
|
||||
total,
|
||||
total_page,
|
||||
page_size: limit,
|
||||
current_page: page,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 年级筛选
|
||||
if (queryDto.grade) {
|
||||
where.grade = queryDto.grade;
|
||||
}
|
||||
|
||||
// 分页参数
|
||||
const page = queryDto.page || 1;
|
||||
const limit = queryDto.limit || 9;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// 查询总数
|
||||
const total = await this.wordRepository.count({ where });
|
||||
|
||||
// 查询分页数据
|
||||
const list = await this.wordRepository.find({
|
||||
where,
|
||||
relations: ['images', 'wordSentences', 'wordSentences.sentence'],
|
||||
order: {
|
||||
grade: 'ASC',
|
||||
createdAt: 'ASC',
|
||||
id: 'ASC',
|
||||
},
|
||||
skip,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
// 计算总页数
|
||||
const total_page = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
list,
|
||||
pagination: {
|
||||
total,
|
||||
total_page,
|
||||
page_size: limit,
|
||||
current_page: page,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 查询单个汉字
|
||||
*/
|
||||
async findOneById(id: number): Promise<Word> {
|
||||
const word = await this.wordRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['images', 'wordSentences', 'wordSentences.sentence'],
|
||||
order: {
|
||||
images: { sortOrder: 'ASC' },
|
||||
wordSentences: { sortOrder: 'ASC' },
|
||||
},
|
||||
});
|
||||
|
||||
if (!word) {
|
||||
throw new NotFoundException(`未找到ID为 ${id} 的汉字`);
|
||||
}
|
||||
|
||||
return word;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新汉字信息
|
||||
*/
|
||||
async update(id: number, updateWordDto: UpdateWordDto): Promise<Word> {
|
||||
const word = await this.findOneById(id);
|
||||
|
||||
// 更新基础信息
|
||||
Object.assign(word, updateWordDto);
|
||||
|
||||
// 更新关联句子
|
||||
if (updateWordDto.sentenceIds) {
|
||||
// 删除现有关联
|
||||
await this.wordSentenceRepository.delete({ wordId: id });
|
||||
|
||||
// 创建新关联
|
||||
for (let i = 0; i < updateWordDto.sentenceIds.length; i++) {
|
||||
const sentenceId = updateWordDto.sentenceIds[i];
|
||||
const wordSentence = this.wordSentenceRepository.create({
|
||||
wordId: id,
|
||||
sentenceId: sentenceId,
|
||||
sortOrder: i,
|
||||
});
|
||||
await this.wordSentenceRepository.save(wordSentence);
|
||||
}
|
||||
}
|
||||
|
||||
return await this.wordRepository.save(word);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除汉字
|
||||
*/
|
||||
async remove(id: number): Promise<void> {
|
||||
const word = await this.findOneById(id);
|
||||
await this.wordRepository.remove(word);
|
||||
}
|
||||
|
||||
// 图片相关方法
|
||||
/**
|
||||
* 获取汉字图片列表
|
||||
*/
|
||||
async getWordImages(wordId: number): Promise<WordImage[]> {
|
||||
return await this.wordImageRepository.find({
|
||||
where: { wordId: wordId },
|
||||
order: { sortOrder: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新图片排序
|
||||
*/
|
||||
async updateWordImagesSort(wordId: number, imageIds: number[]): Promise<WordImage[]> {
|
||||
for (let i = 0; i < imageIds.length; i++) {
|
||||
await this.wordImageRepository.update(
|
||||
{ id: imageIds[i], wordId: wordId },
|
||||
{ sortOrder: i },
|
||||
);
|
||||
}
|
||||
return await this.getWordImages(wordId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除汉字图片
|
||||
*/
|
||||
async deleteWordImage(wordId: number, imageId: number): Promise<void> {
|
||||
const image = await this.wordImageRepository.findOne({
|
||||
where: { id: imageId, wordId: wordId },
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
throw new NotFoundException('图片不存在');
|
||||
}
|
||||
|
||||
await this.wordImageRepository.remove(image);
|
||||
}
|
||||
|
||||
// 句子相关方法
|
||||
/**
|
||||
* 获取汉字句子列表
|
||||
*/
|
||||
async getWordSentences(wordId: number): Promise<Sentence[]> {
|
||||
const wordSentences = await this.wordSentenceRepository.find({
|
||||
where: { wordId: wordId },
|
||||
relations: ['sentence'],
|
||||
order: { sortOrder: 'ASC' },
|
||||
});
|
||||
|
||||
return wordSentences.map((ws) => ws.sentence);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加句子到汉字
|
||||
*/
|
||||
async addSentenceToWord(wordId: number, createSentenceDto: CreateSentenceDto): Promise<Sentence> {
|
||||
// 创建句子
|
||||
const sentence = this.sentenceRepository.create(createSentenceDto);
|
||||
const savedSentence = await this.sentenceRepository.save(sentence);
|
||||
|
||||
// 创建关联
|
||||
const maxSort = await this.wordSentenceRepository
|
||||
.createQueryBuilder('ws')
|
||||
.where('ws.wordId = :wordId', { wordId })
|
||||
.select('MAX(ws.sortOrder)', 'max')
|
||||
.getRawOne();
|
||||
|
||||
const wordSentence = this.wordSentenceRepository.create({
|
||||
wordId: wordId,
|
||||
sentenceId: savedSentence.id,
|
||||
sortOrder: (maxSort?.max || 0) + 1,
|
||||
});
|
||||
|
||||
await this.wordSentenceRepository.save(wordSentence);
|
||||
return savedSentence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从汉字中删除句子
|
||||
*/
|
||||
async removeSentenceFromWord(wordId: number, sentenceId: number): Promise<void> {
|
||||
const wordSentence = await this.wordSentenceRepository.findOne({
|
||||
where: { wordId: wordId, sentenceId: sentenceId },
|
||||
});
|
||||
|
||||
if (!wordSentence) {
|
||||
throw new NotFoundException('句子关联不存在');
|
||||
}
|
||||
|
||||
await this.wordSentenceRepository.remove(wordSentence);
|
||||
}
|
||||
|
||||
// 词语相关方法(后续实现)
|
||||
/**
|
||||
* 获取关联词语
|
||||
*/
|
||||
async getRelatedWords(wordId: number): Promise<Word[]> {
|
||||
// TODO: 实现词语关联查询
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user