feat:初始化项目

This commit is contained in:
R524809
2026-01-23 17:42:48 +08:00
commit d4fc8f44ee
107 changed files with 17487 additions and 0 deletions
+22
View File
@@ -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!');
});
});
});
+12
View File
@@ -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();
}
}
+43
View File
@@ -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 {}
+8
View File
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
+2
View File
@@ -0,0 +1,2 @@
// Word 相关实体已移动到 modules/words 目录
export * from '../modules/users/user.entity';
+41
View File
@@ -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);
}
}
+38
View File
@@ -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 {}
+71
View File
@@ -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;
}
}
+89
View File
@@ -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;
}
+127
View File
@@ -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 {}
+155
View File
@@ -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();
}
}
+233
View File
@@ -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[];
}
+4
View File
@@ -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;
}
+163
View File
@@ -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 {}
+291
View File
@@ -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 [];
}
}