INIT
This commit is contained in:
22
backend/src/app.controller.spec.ts
Normal file
22
backend/src/app.controller.spec.ts
Normal 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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
14
backend/src/app.controller.ts
Normal file
14
backend/src/app.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
import { Public } from './common/decorators/public.decorator';
|
||||
|
||||
@Controller() // 루트 경로 '/'
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Public() // healthcheck를 위해 인증 제외
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
66
backend/src/app.module.ts
Normal file
66
backend/src/app.module.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { RedisModule } from './redis/redis.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { CommonModule } from './common/common.module';
|
||||
import { SharedModule } from './shared/shared.module';
|
||||
import { HelpModule } from './help/help.module';
|
||||
import { JwtModule } from './common/jwt/jwt.module';
|
||||
import { JwtStrategy } from './common/jwt/jwt.strategy';
|
||||
|
||||
// 새로 생성한 모듈들
|
||||
import { FarmModule } from './farm/farm.module';
|
||||
import { CowModule } from './cow/cow.module';
|
||||
import { GenomeModule } from './genome/genome.module';
|
||||
import { MptModule } from './mpt/mpt.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: configService.get('POSTGRES_HOST'),
|
||||
port: configService.get('POSTGRES_PORT'),
|
||||
username: configService.get('POSTGRES_USER'),
|
||||
password: configService.get('POSTGRES_PASSWORD'),
|
||||
database: configService.get('POSTGRES_DB'),
|
||||
synchronize: configService.get('POSTGRES_SYNCHRONIZE'),
|
||||
logging: configService.get('POSTGRES_LOGGING'),
|
||||
autoLoadEntities: true,
|
||||
entities: [],
|
||||
}),
|
||||
}),
|
||||
// 인프라 모듈
|
||||
RedisModule,
|
||||
JwtModule,
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
|
||||
// 인증/사용자 모듈
|
||||
AuthModule,
|
||||
UserModule,
|
||||
|
||||
// 비즈니스 모듈
|
||||
FarmModule,
|
||||
CowModule,
|
||||
GenomeModule,
|
||||
MptModule,
|
||||
DashboardModule,
|
||||
|
||||
// 기타
|
||||
HelpModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService, JwtStrategy],
|
||||
})
|
||||
export class AppModule {}
|
||||
8
backend/src/app.service.ts
Normal file
8
backend/src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
195
backend/src/auth/auth.controller.ts
Normal file
195
backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { Body, Controller, Get, Post, Query, Req } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { LoginResponseDto } from './dto/login-response.dto';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SignupDto } from './dto/signup.dto';
|
||||
import { SignupResponseDto } from './dto/signup-response.dto';
|
||||
import { SendFindIdCodeDto } from './dto/send-find-id-code.dto';
|
||||
import { VerifyFindIdCodeDto } from './dto/verify-find-id-code.dto';
|
||||
import { FindIdResponseDto } from './dto/find-id-response.dto';
|
||||
import { SendResetPasswordCodeDto } from './dto/send-reset-password-code.dto';
|
||||
import { VerifyResetPasswordCodeDto } from './dto/verify-reset-password-code.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { ResetPasswordResponseDto } from './dto/reset-password-response.dto';
|
||||
import { SendSignupCodeDto } from './dto/send-signup-code.dto';
|
||||
import { VerifySignupCodeDto } from './dto/verify-signup-code.dto';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
|
||||
/**
|
||||
* 인증 관련 컨트롤러
|
||||
*
|
||||
* @description
|
||||
* 로그인, 회원가입, 아이디 찾기, 비밀번호 재설정 등 인증 관련 API
|
||||
*
|
||||
* @export
|
||||
* @class AuthController
|
||||
* @typedef {AuthController}
|
||||
*/
|
||||
@Controller('auth')
|
||||
@Public() // 모든 엔드포인트가 공개 (인증 불필요)
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
/**
|
||||
* POST /auth/login - 사용자 로그인 처리
|
||||
*
|
||||
* @async
|
||||
* @param {LoginDto} loginDto
|
||||
* @returns {Promise<LoginResponseDto>}
|
||||
*/
|
||||
@Post('login')
|
||||
async login(@Body() loginDto: LoginDto): Promise<LoginResponseDto> {
|
||||
return this.authService.login(loginDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /auth/check-email - 이메일 중복 체크
|
||||
*
|
||||
* @async
|
||||
* @param {string} email
|
||||
* @returns {Promise<{ available: boolean; message: string }>}
|
||||
*/
|
||||
@Get('check-email')
|
||||
async checkEmail(
|
||||
@Query('email') email: string,
|
||||
): Promise<{ available: boolean; message: string }> {
|
||||
return this.authService.checkEmailDuplicate(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /auth/signup/send-code - 회원가입 이메일 인증번호 발송
|
||||
*
|
||||
* @async
|
||||
* @param {SendSignupCodeDto} dto
|
||||
* @returns {Promise<{ success: boolean; message: string; expiresIn: number }>}
|
||||
*/
|
||||
@Post('signup/send-code')
|
||||
async sendSignupCode(
|
||||
@Body() dto: SendSignupCodeDto,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
expiresIn: number;
|
||||
}> {
|
||||
return this.authService.sendSignupCode(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /auth/signup/verify-code - 회원가입 이메일 인증번호 검증
|
||||
*
|
||||
* @async
|
||||
* @param {VerifySignupCodeDto} dto
|
||||
* @returns {Promise<{ success: boolean; message: string; verified: boolean }>}
|
||||
*/
|
||||
@Post('signup/verify-code')
|
||||
async verifySignupCode(
|
||||
@Body() dto: VerifySignupCodeDto,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
verified: boolean;
|
||||
}> {
|
||||
return this.authService.verifySignupCode(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /auth/register - 회원가입
|
||||
*
|
||||
* @description
|
||||
* 이메일 인증이 완료된 후에만 회원가입이 가능합니다.
|
||||
* 먼저 /auth/signup/send-code로 인증번호를 받고,
|
||||
* /auth/signup/verify-code로 인증을 완료한 후 호출하세요.
|
||||
*
|
||||
* @async
|
||||
* @param {SignupDto} signupDto
|
||||
* @param {Request} req
|
||||
* @returns {Promise<SignupResponseDto>}
|
||||
*/
|
||||
@Post('register')
|
||||
async register(
|
||||
@Body() signupDto: SignupDto,
|
||||
@Req() req: Request,
|
||||
): Promise<SignupResponseDto> {
|
||||
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
return this.authService.register(signupDto, clientIp);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /auth/find-id/send-code - 아이디 찾기 인증번호 발송
|
||||
*
|
||||
* @async
|
||||
* @param {SendFindIdCodeDto} dto
|
||||
* @returns {Promise<{ success: boolean; message: string; expiresIn: number }>}
|
||||
*/
|
||||
@Post('find-id/send-code')
|
||||
async sendFindIdCode(
|
||||
@Body() dto: SendFindIdCodeDto,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
expiresIn: number;
|
||||
}> {
|
||||
return this.authService.sendFindIdCode(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /auth/find-id/verify-code - 아이디 찾기 인증번호 검증
|
||||
*
|
||||
* @async
|
||||
* @param {VerifyFindIdCodeDto} dto
|
||||
* @returns {Promise<FindIdResponseDto>}
|
||||
*/
|
||||
@Post('find-id/verify-code')
|
||||
async verifyFindIdCode(
|
||||
@Body() dto: VerifyFindIdCodeDto,
|
||||
): Promise<FindIdResponseDto> {
|
||||
return this.authService.verifyFindIdCode(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /auth/reset-password/send-code - 비밀번호 재설정 인증번호 발송
|
||||
*
|
||||
* @async
|
||||
* @param {SendResetPasswordCodeDto} dto
|
||||
* @returns {Promise<{ success: boolean; message: string; expiresIn: number }>}
|
||||
*/
|
||||
@Post('reset-password/send-code')
|
||||
async sendResetPasswordCode(
|
||||
@Body() dto: SendResetPasswordCodeDto,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
expiresIn: number;
|
||||
}> {
|
||||
return this.authService.sendResetPasswordCode(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /auth/reset-password/verify-code - 비밀번호 재설정 인증번호 검증
|
||||
*
|
||||
* @async
|
||||
* @param {VerifyResetPasswordCodeDto} dto
|
||||
* @returns {Promise<{ success: boolean; message: string; resetToken: string }>}
|
||||
*/
|
||||
@Post('reset-password/verify-code')
|
||||
async verifyResetPasswordCode(
|
||||
@Body() dto: VerifyResetPasswordCodeDto,
|
||||
): Promise<{ success: boolean; message: string; resetToken: string }> {
|
||||
return this.authService.verifyResetPasswordCode(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /auth/reset-password - 비밀번호 재설정 실행
|
||||
*
|
||||
* @async
|
||||
* @param {ResetPasswordDto} dto
|
||||
* @returns {Promise<ResetPasswordResponseDto>}
|
||||
*/
|
||||
@Post('reset-password')
|
||||
async resetPassword(
|
||||
@Body() dto: ResetPasswordDto,
|
||||
): Promise<ResetPasswordResponseDto> {
|
||||
return this.authService.resetPassword(dto);
|
||||
}
|
||||
}
|
||||
25
backend/src/auth/auth.module.ts
Normal file
25
backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UserModel } from '../user/entities/user.entity';
|
||||
import { JwtModule } from 'src/common/jwt/jwt.module';
|
||||
import { EmailModule } from 'src/shared/email/email.module';
|
||||
import { VerificationModule } from 'src/shared/verification/verification.module';
|
||||
|
||||
/**
|
||||
* 인증 모듈
|
||||
* 로그인, 회원가입, 비밀번호 재설정 등 인증 관련 기능 제공
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([UserModel]),
|
||||
JwtModule,
|
||||
EmailModule,
|
||||
VerificationModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
493
backend/src/auth/auth.service.ts
Normal file
493
backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { UserModel } from '../user/entities/user.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { LoginResponseDto } from './dto/login-response.dto';
|
||||
import { SignupDto } from './dto/signup.dto';
|
||||
import { SignupResponseDto } from './dto/signup-response.dto';
|
||||
import { SendFindIdCodeDto } from './dto/send-find-id-code.dto';
|
||||
import { VerifyFindIdCodeDto } from './dto/verify-find-id-code.dto';
|
||||
import { FindIdResponseDto } from './dto/find-id-response.dto';
|
||||
import { SendResetPasswordCodeDto } from './dto/send-reset-password-code.dto';
|
||||
import { VerifyResetPasswordCodeDto } from './dto/verify-reset-password-code.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { ResetPasswordResponseDto } from './dto/reset-password-response.dto';
|
||||
import { SendSignupCodeDto } from './dto/send-signup-code.dto';
|
||||
import { VerifySignupCodeDto } from './dto/verify-signup-code.dto';
|
||||
|
||||
import { EmailService } from 'src/shared/email/email.service';
|
||||
import { VerificationService } from 'src/shared/verification/verification.service';
|
||||
import { VERIFICATION_CONFIG } from 'src/common/config/VerificationConfig';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@InjectRepository(UserModel)
|
||||
private readonly userRepository: Repository<UserModel>,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly verificationService: VerificationService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 유저 로그인
|
||||
*
|
||||
* @async
|
||||
* @param {LoginDto} loginDto
|
||||
* @returns {Promise<LoginResponseDto>}
|
||||
*/
|
||||
async login(loginDto: LoginDto): Promise<LoginResponseDto> {
|
||||
const { userId, userPassword } = loginDto;
|
||||
|
||||
// 1. userId로 유저 찾기
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { userId },
|
||||
});
|
||||
// 2. user 없으면 에러
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('아이디 또는 비밀번호가 틀렸습니다'); //HTTP 401 상태 코드 예외
|
||||
}
|
||||
// 3. 비밀번호 비교 (bcrypt)
|
||||
const isPasswordValid = await bcrypt.compare(userPassword, user.userPw);
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('아이디 또는 비밀번호가 틀렸습니다');
|
||||
}
|
||||
|
||||
// 4. 탈퇴 여부 확인
|
||||
if (user.delDt !== null) {
|
||||
throw new UnauthorizedException('탈퇴한 계정입니다');
|
||||
}
|
||||
// 6. JWT 토큰 생성
|
||||
const payload = {
|
||||
userId: user.userId,
|
||||
userNo: user.pkUserNo,
|
||||
|
||||
};
|
||||
|
||||
// Access Token 생성 (기본 설정 사용)
|
||||
const accessToken = this.jwtService.sign(payload as any);
|
||||
|
||||
// Refresh Token 생성 (별도 secret과 만료시간 사용)
|
||||
const refreshOptions = {
|
||||
secret: this.configService.get<string>('JWT_REFRESH_SECRET')!,
|
||||
expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES_IN') || '7d',
|
||||
};
|
||||
const refreshToken = this.jwtService.sign(payload as any, refreshOptions as any);
|
||||
|
||||
// 7. 로그인 응답 생성 (LoginResponseDto)
|
||||
return {
|
||||
message: '로그인 성공',
|
||||
accessToken, // JWT 토큰 추가
|
||||
refreshToken, // JWT 토큰 추가
|
||||
user: {
|
||||
pkUserNo: user.pkUserNo,
|
||||
userId: user.userId,
|
||||
userName: user.userName,
|
||||
userEmail: user.userEmail,
|
||||
userRole: user.userRole || 'USER',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입
|
||||
*
|
||||
* @async
|
||||
* @param {SignupDto} signupDto
|
||||
* @returns {Promise<SignupResponseDto>}
|
||||
*/
|
||||
async register(signupDto: SignupDto, clientIp: string): Promise<SignupResponseDto> {
|
||||
const { userId, userEmail, userPhone } = signupDto;
|
||||
|
||||
// 0. 이메일 인증 확인 (Redis에 인증 완료 여부 체크)
|
||||
const verifiedKey = `signup-verified:${userEmail}`;
|
||||
const isEmailVerified = await this.verificationService.verifyCode(verifiedKey, 'true');
|
||||
|
||||
if (!isEmailVerified) {
|
||||
throw new UnauthorizedException('이메일 인증이 완료되지 않았습니다');
|
||||
}
|
||||
|
||||
// 1. 중복 체크 (ID, 이메일, 전화번호, 사업자번호)
|
||||
const whereConditions = [{ userId }, { userEmail }, { userPhone }];
|
||||
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: whereConditions,
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
if (existingUser.userId === userId) {
|
||||
throw new ConflictException('이미 사용 중인 아이디입니다'); //HTTP 409 상태 코드 예외
|
||||
}
|
||||
if (existingUser.userEmail === userEmail) {
|
||||
throw new ConflictException('이미 사용 중인 이메일입니다');
|
||||
}
|
||||
if (existingUser.userPhone === userPhone) {
|
||||
throw new ConflictException('이미 사용 중인 전화번호입니다');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 2. 비밀번호 해싱 (bcrypt)
|
||||
const saltRounds = parseInt(this.configService.get<string>('BCRYPT_SALT_ROUNDS') || '10', 10);
|
||||
const hashedPassword = await bcrypt.hash(signupDto.userPassword, saltRounds);
|
||||
// 3. 사용자 생성
|
||||
const newUser = this.userRepository.create({
|
||||
userId: signupDto.userId,
|
||||
userPw: hashedPassword,
|
||||
userName: signupDto.userName,
|
||||
userPhone: signupDto.userPhone,
|
||||
userEmail: signupDto.userEmail,
|
||||
|
||||
regIp: clientIp, // 등록 ip
|
||||
regUserId: signupDto.userId,
|
||||
});
|
||||
|
||||
// 4. DB에 저장
|
||||
const savedUser = await this.userRepository.save(newUser);
|
||||
|
||||
// 5. 응답 구조 생성 (SignupResponseDto 반환)
|
||||
return {
|
||||
message: '회원가입이 완료되었습니다',
|
||||
redirectUrl: '/dashboard',
|
||||
userId: savedUser.userId,
|
||||
userNo: savedUser.pkUserNo,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 중복 체크
|
||||
*
|
||||
* @async
|
||||
* @param {string} userEmail
|
||||
* @returns {Promise<{ available: boolean; message: string }>}
|
||||
*/
|
||||
async checkEmailDuplicate(userEmail: string): Promise<{
|
||||
available: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: { userEmail },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return {
|
||||
available: false,
|
||||
message: '이미 사용 중인 이메일입니다',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
message: '사용 가능한 이메일입니다',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 - 이메일 인증번호 발송
|
||||
*
|
||||
* @async
|
||||
* @param {SendSignupCodeDto} dto
|
||||
* @returns {Promise<{ success: boolean; message: string; expiresIn: number }>}
|
||||
*/
|
||||
async sendSignupCode(dto: SendSignupCodeDto): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
expiresIn: number;
|
||||
}> {
|
||||
const { userEmail } = dto;
|
||||
|
||||
// 1. 이메일 중복 체크
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: { userEmail },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new ConflictException('이미 사용 중인 이메일입니다');
|
||||
}
|
||||
|
||||
// 2. 인증번호 생성
|
||||
const code = this.verificationService.generateCode();
|
||||
console.log(`[DEBUG] Generated code for ${userEmail}: ${code}`);
|
||||
|
||||
// 3. Redis에 저장 (key: signup:이메일)
|
||||
const key = `signup:${userEmail}`;
|
||||
await this.verificationService.saveCode(key, code);
|
||||
console.log(`[DEBUG] Saved code to Redis with key: ${key}`);
|
||||
|
||||
// 4. 이메일 발송
|
||||
await this.emailService.sendVerificationCode(userEmail, code);
|
||||
console.log(`[DEBUG] Email sent to: ${userEmail}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '인증번호가 이메일로 발송되었습니다',
|
||||
expiresIn: VERIFICATION_CONFIG.CODE_EXPIRY_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 - 이메일 인증번호 검증
|
||||
*
|
||||
* @async
|
||||
* @param {VerifySignupCodeDto} dto
|
||||
* @returns {Promise<{ success: boolean; message: string; verified: boolean }>}
|
||||
*/
|
||||
async verifySignupCode(dto: VerifySignupCodeDto): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
verified: boolean;
|
||||
}> {
|
||||
const { userEmail, code } = dto;
|
||||
console.log(`[DEBUG] Verifying code for ${userEmail}: ${code}`);
|
||||
|
||||
// Redis에서 검증
|
||||
const key = `signup:${userEmail}`;
|
||||
const isValid = await this.verificationService.verifyCode(key, code);
|
||||
console.log(`[DEBUG] Verification result: ${isValid}`);
|
||||
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('인증번호가 일치하지 않거나 만료되었습니다');
|
||||
}
|
||||
|
||||
// 검증 완료 표시 (5분간 유효)
|
||||
const verifiedKey = `signup-verified:${userEmail}`;
|
||||
await this.verificationService.saveCode(verifiedKey, 'true');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '이메일 인증이 완료되었습니다',
|
||||
verified: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 아이디 찾기 - 인증번호 발송 (이메일 인증)
|
||||
*
|
||||
* @async
|
||||
* @param {SendFindIdCodeDto} dto
|
||||
* @returns {Promise<{ success: boolean; message: string; expiresIn: number }>}
|
||||
*/
|
||||
async sendFindIdCode(
|
||||
dto: SendFindIdCodeDto,
|
||||
): Promise<{ success: boolean; message: string; expiresIn: number }> {
|
||||
const { userName, userEmail } = dto;
|
||||
console.log(`[아이디 찾기] 인증번호 발송 요청 - 이름: ${userName}, 이메일: ${userEmail}`);
|
||||
|
||||
// 1. 사용자 확인
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { userName, userEmail },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('일치하는 사용자 정보를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
// 2. 인증번호 생성
|
||||
const code = this.verificationService.generateCode();
|
||||
console.log(`[아이디 찾기] 생성된 인증번호: ${code} (이메일: ${userEmail})`);
|
||||
|
||||
// 3. Redis에 저장 (key: find-id:이메일)
|
||||
const key = `find-id:${userEmail}`;
|
||||
await this.verificationService.saveCode(key, code);
|
||||
console.log(`[아이디 찾기] Redis 저장 완료 - Key: ${key}`);
|
||||
|
||||
// 4. 이메일 발송
|
||||
await this.emailService.sendVerificationCode(userEmail, code);
|
||||
console.log(`[아이디 찾기] 이메일 발송 완료 - 수신자: ${userEmail}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '인증번호가 이메일로 발송되었습니다',
|
||||
expiresIn: VERIFICATION_CONFIG.CODE_EXPIRY_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 아이디 찾기 - 인증번호 검증
|
||||
*
|
||||
* @async
|
||||
* @param {VerifyFindIdCodeDto} dto
|
||||
* @returns {Promise<FindIdResponseDto>}
|
||||
*/
|
||||
async verifyFindIdCode(dto: VerifyFindIdCodeDto): Promise<FindIdResponseDto> {
|
||||
const { userEmail, verificationCode } = dto;
|
||||
console.log(`[아이디 찾기] 인증번호 검증 요청 - 이메일: ${userEmail}, 입력 코드: ${verificationCode}`);
|
||||
|
||||
// 1. 인증번호 검증
|
||||
const key = `find-id:${userEmail}`;
|
||||
console.log(`[아이디 찾기] 검증 Key: ${key}`);
|
||||
const isValid = await this.verificationService.verifyCode(key, verificationCode);
|
||||
console.log(`[아이디 찾기] 검증 결과: ${isValid}`);
|
||||
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('인증번호가 일치하지 않거나 만료되었습니다');
|
||||
}
|
||||
|
||||
// 2. 사용자 정보 조회
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { userEmail },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('사용자를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
// 3. 아이디 마스킹
|
||||
const maskedUserId = this.maskUserId(user.userId);
|
||||
|
||||
return {
|
||||
message: '인증이 완료되었습니다',
|
||||
userId: user.userId,
|
||||
maskedUserId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 아이디 마스킹 (앞 4자리만 표시)
|
||||
*
|
||||
* @private
|
||||
* @param {string} userId
|
||||
* @returns {string}
|
||||
*/
|
||||
private maskUserId(userId: string): string {
|
||||
if (userId.length <= 4) {
|
||||
return userId;
|
||||
}
|
||||
const visiblePart = userId.substring(0, 4);
|
||||
const maskedPart = '*'.repeat(userId.length - 4);
|
||||
return visiblePart + maskedPart;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 재설정 - 인증번호 발송 (이메일 인증)
|
||||
*
|
||||
* @async
|
||||
* @param {SendResetPasswordCodeDto} dto
|
||||
* @returns {Promise<{ success: boolean; message: string; expiresIn: number }>}
|
||||
*/
|
||||
async sendResetPasswordCode(
|
||||
dto: SendResetPasswordCodeDto,
|
||||
): Promise<{ success: boolean; message: string; expiresIn: number }> {
|
||||
const { userId, userEmail } = dto;
|
||||
console.log(`[비밀번호 찾기] 인증번호 발송 요청 - 아이디: ${userId}, 이메일: ${userEmail}`);
|
||||
|
||||
// 1. 사용자 확인
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { userId, userEmail },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('일치하는 사용자 정보를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
// 2. 인증번호 생성
|
||||
const code = this.verificationService.generateCode();
|
||||
console.log(`[비밀번호 찾기] 생성된 인증번호: ${code} (이메일: ${userEmail})`);
|
||||
|
||||
// 3. Redis에 저장 (key: reset-pw:이메일)
|
||||
const key = `reset-pw:${userEmail}`;
|
||||
await this.verificationService.saveCode(key, code);
|
||||
console.log(`[비밀번호 찾기] Redis 저장 완료 - Key: ${key}`);
|
||||
|
||||
// 4. 이메일 발송
|
||||
await this.emailService.sendVerificationCode(userEmail, code);
|
||||
console.log(`[비밀번호 찾기] 이메일 발송 완료 - 수신자: ${userEmail}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '인증번호가 이메일로 발송되었습니다',
|
||||
expiresIn: VERIFICATION_CONFIG.CODE_EXPIRY_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 재설정 - 인증번호 검증 및 재설정 토큰 발급
|
||||
*
|
||||
* @async
|
||||
* @param {VerifyResetPasswordCodeDto} dto
|
||||
* @returns {Promise<{ success: boolean; message: string; resetToken: string }>}
|
||||
*/
|
||||
async verifyResetPasswordCode(
|
||||
dto: VerifyResetPasswordCodeDto,
|
||||
): Promise<{ success: boolean; message: string; resetToken: string }> {
|
||||
const { userId, userEmail, verificationCode } = dto;
|
||||
console.log(`[비밀번호 찾기] 인증번호 검증 요청 - 아이디: ${userId}, 이메일: ${userEmail}, 입력 코드: ${verificationCode}`);
|
||||
|
||||
// 1. 인증번호 검증
|
||||
const key = `reset-pw:${userEmail}`;
|
||||
console.log(`[비밀번호 찾기] 검증 Key: ${key}`);
|
||||
const isValid = await this.verificationService.verifyCode(key, verificationCode);
|
||||
console.log(`[비밀번호 찾기] 검증 결과: ${isValid}`);
|
||||
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('인증번호가 일치하지 않거나 만료되었습니다');
|
||||
}
|
||||
|
||||
// 2. 사용자 확인
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { userId, userEmail },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('사용자를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
// 3. 비밀번호 재설정 토큰 생성 및 저장 (30분 유효)
|
||||
const resetToken = await this.verificationService.generateResetToken(userId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '인증이 완료되었습니다',
|
||||
resetToken,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 재설정 - 새 비밀번호로 변경
|
||||
*
|
||||
* @async
|
||||
* @param {ResetPasswordDto} dto
|
||||
* @returns {Promise<ResetPasswordResponseDto>}
|
||||
*/
|
||||
async resetPassword(dto: ResetPasswordDto): Promise<ResetPasswordResponseDto> {
|
||||
const { resetToken, newPassword } = dto; // 요청
|
||||
|
||||
// 1. 재설정 토큰 검증
|
||||
const userId = await this.verificationService.verifyResetToken(resetToken);
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException('유효하지 않거나 만료된 토큰입니다');
|
||||
}
|
||||
|
||||
// 2. 사용자 조회
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('사용자를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
// 3. 새 비밀번호 해싱
|
||||
const saltRounds = parseInt(this.configService.get<string>('BCRYPT_SALT_ROUNDS') || '10', 10);
|
||||
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
||||
|
||||
// 4. 비밀번호 업데이트
|
||||
user.userPw = hashedPassword;
|
||||
await this.userRepository.save(user);
|
||||
|
||||
return {
|
||||
message: '비밀번호가 변경되었습니다',
|
||||
};
|
||||
}
|
||||
}
|
||||
28
backend/src/auth/dto/find-id-response.dto.ts
Normal file
28
backend/src/auth/dto/find-id-response.dto.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 아이디 찾기 응답 DTO
|
||||
* 표준화된 응답 구조 정의
|
||||
*
|
||||
* @export
|
||||
* @class FindIdResponseDto
|
||||
* @typedef {FindIdResponseDto}
|
||||
*/
|
||||
export class FindIdResponseDto {
|
||||
/**
|
||||
* 응답 메시지
|
||||
* @type {string}
|
||||
*/
|
||||
message: string;
|
||||
|
||||
/**
|
||||
* 찾은 사용자 ID
|
||||
* @type {string}
|
||||
*/
|
||||
userId: string;
|
||||
|
||||
/**
|
||||
* 마스킹된 사용자 ID (선택 - 보안 강화)
|
||||
* 예: "testuser" -> "test****"
|
||||
* @type {string}
|
||||
*/
|
||||
maskedUserId?: string;
|
||||
}
|
||||
15
backend/src/auth/dto/login-response.dto.ts
Normal file
15
backend/src/auth/dto/login-response.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 로그인 응답 DTO
|
||||
*/
|
||||
export class LoginResponseDto {
|
||||
message: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
user: {
|
||||
pkUserNo: number;
|
||||
userId: string;
|
||||
userName: string;
|
||||
userEmail: string;
|
||||
userRole: 'USER' | 'ADMIN';
|
||||
};
|
||||
}
|
||||
16
backend/src/auth/dto/login.dto.ts
Normal file
16
backend/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
/**
|
||||
* 클라이언트 로그인 데이터 검증(Validation)
|
||||
*
|
||||
* @export
|
||||
* @class LoginDto
|
||||
* @typedef {LoginDto}
|
||||
*/
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
userId: string; // 사용자 ID (로그인 ID)
|
||||
|
||||
@IsString()
|
||||
userPassword: string; // 비밀번호
|
||||
}
|
||||
21
backend/src/auth/dto/reset-password-response.dto.ts
Normal file
21
backend/src/auth/dto/reset-password-response.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 비밀번호 재설정 응답 DTO
|
||||
* 표준화된 응답 구조 정의
|
||||
*
|
||||
* @export
|
||||
* @class ResetPasswordResponseDto
|
||||
* @typedef {ResetPasswordResponseDto}
|
||||
*/
|
||||
export class ResetPasswordResponseDto {
|
||||
/**
|
||||
* 응답 메시지
|
||||
* @type {string}
|
||||
*/
|
||||
message: string;
|
||||
|
||||
/**
|
||||
* 임시 비밀번호 (개발 환경에서만 반환, 실무에서는 SMS 발송)
|
||||
* @type {string}
|
||||
*/
|
||||
tempPassword?: string;
|
||||
}
|
||||
18
backend/src/auth/dto/reset-password.dto.ts
Normal file
18
backend/src/auth/dto/reset-password.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 비밀번호 재설정 요청 DTO
|
||||
*
|
||||
* @export
|
||||
* @class ResetPasswordDto
|
||||
*/
|
||||
export class ResetPasswordDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
resetToken: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(6)
|
||||
newPassword: string;
|
||||
}
|
||||
17
backend/src/auth/dto/send-find-id-code.dto.ts
Normal file
17
backend/src/auth/dto/send-find-id-code.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 아이디 찾기 인증번호 발송 요청 DTO
|
||||
*
|
||||
* @export
|
||||
* @class SendFindIdCodeDto
|
||||
*/
|
||||
export class SendFindIdCodeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
userName: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
userEmail: string;
|
||||
}
|
||||
17
backend/src/auth/dto/send-reset-password-code.dto.ts
Normal file
17
backend/src/auth/dto/send-reset-password-code.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 비밀번호 재설정 인증번호 발송 요청 DTO
|
||||
*
|
||||
* @export
|
||||
* @class SendResetPasswordCodeDto
|
||||
*/
|
||||
export class SendResetPasswordCodeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
userId: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
userEmail: string;
|
||||
}
|
||||
13
backend/src/auth/dto/send-signup-code.dto.ts
Normal file
13
backend/src/auth/dto/send-signup-code.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsEmail, IsNotEmpty } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 회원가입 인증번호 발송 DTO
|
||||
*
|
||||
* @export
|
||||
* @class SendSignupCodeDto
|
||||
*/
|
||||
export class SendSignupCodeDto {
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
userEmail: string;
|
||||
}
|
||||
33
backend/src/auth/dto/signup-response.dto.ts
Normal file
33
backend/src/auth/dto/signup-response.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 회원가입 응답 DTO
|
||||
* 표준화된 응답 구조 정의
|
||||
*
|
||||
* @export
|
||||
* @class SignupResponseDto
|
||||
* @typedef {SignupResponseDto}
|
||||
*/
|
||||
export class SignupResponseDto {
|
||||
/**
|
||||
* 응답 메시지
|
||||
* @type {string}
|
||||
*/
|
||||
message: string;
|
||||
|
||||
/**
|
||||
* 리다이렉트 URL
|
||||
* @type {string}
|
||||
*/
|
||||
redirectUrl: string;
|
||||
|
||||
/**
|
||||
* 생성된 사용자 ID (선택)
|
||||
* @type {string}
|
||||
*/
|
||||
userId?: string;
|
||||
|
||||
/**
|
||||
* 생성된 사용자 번호 (선택)
|
||||
* @type {number}
|
||||
*/
|
||||
userNo?: number;
|
||||
}
|
||||
52
backend/src/auth/dto/signup.dto.ts
Normal file
52
backend/src/auth/dto/signup.dto.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { UserSeType } from 'src/common/const/UserSeType';
|
||||
|
||||
/**
|
||||
* 클라이언트 회원가입 데이터 검증(Validation)
|
||||
*
|
||||
* @export
|
||||
* @class SignupDto
|
||||
* @typedef {SignupDto}
|
||||
*/
|
||||
export class SignupDto {
|
||||
@IsEnum(UserSeType)
|
||||
@IsNotEmpty()
|
||||
userSe: UserSeType; // 사용자 구분 (FARM/CNSLT/ORGAN)
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
userInstName?: string; // 농장명/기관명
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(4)
|
||||
userId: string; // 사용자 ID
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(6)
|
||||
userPassword: string; // 비밀번호
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
userName: string; // 이름
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
userPhone: string; // 휴대폰 번호
|
||||
|
||||
@IsOptional()
|
||||
userBirth?: Date; // 생년월일
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
userEmail: string; // 이메일
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
userAddress?: string; // 주소
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
userBizNo?: string; // 사업자등록번호
|
||||
}
|
||||
18
backend/src/auth/dto/verify-find-id-code.dto.ts
Normal file
18
backend/src/auth/dto/verify-find-id-code.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IsEmail, IsNotEmpty, IsString, Length } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 아이디 찾기 인증번호 검증 요청 DTO
|
||||
*
|
||||
* @export
|
||||
* @class VerifyFindIdCodeDto
|
||||
*/
|
||||
export class VerifyFindIdCodeDto {
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
userEmail: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(6, 6)
|
||||
verificationCode: string;
|
||||
}
|
||||
22
backend/src/auth/dto/verify-reset-password-code.dto.ts
Normal file
22
backend/src/auth/dto/verify-reset-password-code.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { IsEmail, IsNotEmpty, IsString, Length } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 비밀번호 재설정 인증번호 검증 요청 DTO
|
||||
*
|
||||
* @export
|
||||
* @class VerifyResetPasswordCodeDto
|
||||
*/
|
||||
export class VerifyResetPasswordCodeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
userId: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
userEmail: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(6, 6)
|
||||
verificationCode: string;
|
||||
}
|
||||
17
backend/src/auth/dto/verify-signup-code.dto.ts
Normal file
17
backend/src/auth/dto/verify-signup-code.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 회원가입 인증번호 검증 DTO
|
||||
*
|
||||
* @export
|
||||
* @class VerifySignupCodeDto
|
||||
*/
|
||||
export class VerifySignupCodeDto {
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
userEmail: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code: string;
|
||||
}
|
||||
20
backend/src/common/common.controller.spec.ts
Normal file
20
backend/src/common/common.controller.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { CommonController } from './common.controller';
|
||||
import { CommonService } from './common.service';
|
||||
|
||||
describe('CommonController', () => {
|
||||
let controller: CommonController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [CommonController],
|
||||
providers: [CommonService],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<CommonController>(CommonController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
7
backend/src/common/common.controller.ts
Normal file
7
backend/src/common/common.controller.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Controller } from '@nestjs/common';
|
||||
import { CommonService } from './common.service';
|
||||
|
||||
@Controller('common')
|
||||
export class CommonController {
|
||||
constructor(private readonly commonService: CommonService) {}
|
||||
}
|
||||
32
backend/src/common/common.module.ts
Normal file
32
backend/src/common/common.module.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommonController } from './common.controller';
|
||||
import { CommonService } from './common.service';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { JwtStrategy } from './jwt/jwt.strategy';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { HttpExceptionFilter } from './filters/http-exception.filter';
|
||||
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
|
||||
import { LoggingInterceptor } from './interceptors/logging.interceptor';
|
||||
import { TransformInterceptor } from './interceptors/transform.interceptor';
|
||||
|
||||
@Module({
|
||||
controllers: [CommonController],
|
||||
providers: [
|
||||
CommonService,
|
||||
JwtStrategy,
|
||||
JwtAuthGuard,
|
||||
HttpExceptionFilter,
|
||||
AllExceptionsFilter,
|
||||
LoggingInterceptor,
|
||||
TransformInterceptor,
|
||||
],
|
||||
exports: [
|
||||
JwtStrategy,
|
||||
JwtAuthGuard,
|
||||
HttpExceptionFilter,
|
||||
AllExceptionsFilter,
|
||||
LoggingInterceptor,
|
||||
TransformInterceptor,
|
||||
],
|
||||
})
|
||||
export class CommonModule {}
|
||||
18
backend/src/common/common.service.spec.ts
Normal file
18
backend/src/common/common.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { CommonService } from './common.service';
|
||||
|
||||
describe('CommonService', () => {
|
||||
let service: CommonService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [CommonService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CommonService>(CommonService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
4
backend/src/common/common.service.ts
Normal file
4
backend/src/common/common.service.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class CommonService {}
|
||||
61
backend/src/common/config/CowPurposeConfig.ts
Normal file
61
backend/src/common/config/CowPurposeConfig.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 소 용도 분류 설정
|
||||
*
|
||||
* @description
|
||||
* 소의 용도를 결정하는 비즈니스 로직 임계값 정의
|
||||
* - 도태 (Culling): 낮은 수태율, 번식 능력 부족
|
||||
* - 인공수정 (Artificial Insemination): 높은 수태율 + 우수 등급
|
||||
* - 공란우 (Donor): 중간 수태율 + 우수 등급
|
||||
* - 수란우 (Recipient): 높은 수태율 + 낮은 등급
|
||||
*/
|
||||
export const COW_PURPOSE_CONFIG = {
|
||||
/**
|
||||
* 수태율 기반 임계값 (%)
|
||||
*/
|
||||
CONCEPTION_RATE_THRESHOLDS: {
|
||||
/**
|
||||
* 도태 대상 최대 수태율 (30% 미만)
|
||||
* 수태율이 이 값보다 낮으면 번식 능력이 부족하여 도태 대상
|
||||
*/
|
||||
CULLING_MAX: 30,
|
||||
|
||||
/**
|
||||
* 공란우 최대 수태율 (50% 미만)
|
||||
* 수태율이 낮지만 우수한 유전자 보유 시 수정란 공급
|
||||
*/
|
||||
DONOR_MAX: 50,
|
||||
|
||||
/**
|
||||
* 수란우 최소 수태율 (65% 이상)
|
||||
* 높은 수태율을 가진 소에게 우수 수정란 이식
|
||||
*/
|
||||
RECIPIENT_MIN: 65,
|
||||
|
||||
/**
|
||||
* 인공수정 최소 수태율 (65% 이상)
|
||||
* 높은 수태율 + 우수 등급 → 일반 인공수정 대상
|
||||
*/
|
||||
INSEMINATION_MIN: 65,
|
||||
},
|
||||
|
||||
/**
|
||||
* 나이 기반 임계값 (년)
|
||||
*/
|
||||
AGE_THRESHOLDS: {
|
||||
/**
|
||||
* 노령우 기준 (10년 이상)
|
||||
* 이 나이 이상이면 도태 고려 대상
|
||||
*/
|
||||
OLD_AGE_YEARS: 10,
|
||||
|
||||
/**
|
||||
* 번식 적정 최소 나이 (2년)
|
||||
*/
|
||||
BREEDING_MIN_AGE: 2,
|
||||
|
||||
/**
|
||||
* 번식 적정 최대 나이 (8년)
|
||||
*/
|
||||
BREEDING_MAX_AGE: 8,
|
||||
},
|
||||
} as const;
|
||||
85
backend/src/common/config/GenomeAnalysisConfig.ts
Normal file
85
backend/src/common/config/GenomeAnalysisConfig.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 유전체 분석 유효성 조건 설정
|
||||
*
|
||||
* @description
|
||||
* 유전체 분석 데이터가 유효한지 판단하는 조건 정의
|
||||
*
|
||||
* 유효 조건:
|
||||
* 1. chipSireName === '일치' (아비 칩 데이터 일치)
|
||||
* 2. chipDamName !== '불일치' (어미 칩 데이터 불일치가 아님)
|
||||
* 3. chipDamName !== '이력제부재' (어미 이력제 부재가 아님)
|
||||
* 4. cowId가 EXCLUDED_COW_IDS에 포함되지 않음
|
||||
*
|
||||
* 제외되는 경우:
|
||||
* - chipSireName !== '일치' (아비 불일치, 이력제부재 등)
|
||||
* - chipDamName === '불일치' (어미 불일치)
|
||||
* - chipDamName === '이력제부재' (어미 이력제 부재)
|
||||
* - 분석불가 개체 (EXCLUDED_COW_IDS)
|
||||
*/
|
||||
|
||||
/** 유효한 아비 칩 이름 값 */
|
||||
export const VALID_CHIP_SIRE_NAME = '일치';
|
||||
|
||||
/** 제외할 어미 칩 이름 값 목록 */
|
||||
export const INVALID_CHIP_DAM_NAMES = ['불일치', '이력제부재'];
|
||||
|
||||
/** ===============================개별 제외 개체 목록 (분석불가 등 특수 사유) 하단 개체 정보없음 확인필요=================*/
|
||||
export const EXCLUDED_COW_IDS = [
|
||||
'KOR002191642861', // 1회 분석 반려내역서 재분석 불가능
|
||||
];
|
||||
//=================================================================================================================
|
||||
|
||||
/** @deprecated INVALID_CHIP_DAM_NAMES 사용 권장 */
|
||||
export const INVALID_CHIP_DAM_NAME = '불일치';
|
||||
|
||||
/**
|
||||
* 유전체 분석 데이터 유효성 검사
|
||||
*
|
||||
* @param chipSireName - 아비 칩 이름 (친자감별 결과)
|
||||
* @param chipDamName - 어미 칩 이름 (친자감별 결과)
|
||||
* @param cowId - 개체식별번호 (선택, 개별 제외 목록 확인용)
|
||||
* @returns 유효한 분석 데이터인지 여부
|
||||
*
|
||||
* @example
|
||||
* // 유효한 경우
|
||||
* isValidGenomeAnalysis('일치', '일치') // true
|
||||
* isValidGenomeAnalysis('일치', null) // true
|
||||
* isValidGenomeAnalysis('일치', '정보없음') // true
|
||||
*
|
||||
* // 유효하지 않은 경우
|
||||
* isValidGenomeAnalysis('불일치', '일치') // false (아비 불일치)
|
||||
* isValidGenomeAnalysis('일치', '불일치') // false (어미 불일치)
|
||||
* isValidGenomeAnalysis('일치', '이력제부재') // false (어미 이력제부재)
|
||||
* isValidGenomeAnalysis('일치', '일치', 'KOR002191642861') // false (제외 개체)
|
||||
*/
|
||||
export function isValidGenomeAnalysis(
|
||||
chipSireName: string | null | undefined,
|
||||
chipDamName: string | null | undefined,
|
||||
cowId?: string | null,
|
||||
): boolean {
|
||||
// 1. 아비 일치 확인
|
||||
if (chipSireName !== VALID_CHIP_SIRE_NAME) return false;
|
||||
|
||||
// 2. 어미 제외 조건 확인
|
||||
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) return false;
|
||||
|
||||
// 3. 개별 제외 개체 확인
|
||||
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL WHERE 조건 생성 (TypeORM QueryBuilder용)
|
||||
* 주의: cowId 제외 목록은 SQL에 포함되지 않으므로 별도 필터링 필요
|
||||
*
|
||||
* @param alias - 테이블 별칭 (예: 'request', 'genome')
|
||||
* @returns SQL 조건 문자열
|
||||
*
|
||||
* @example
|
||||
* queryBuilder.andWhere(getValidGenomeConditionSQL('request'));
|
||||
*/
|
||||
export function getValidGenomeConditionSQL(alias: string): string {
|
||||
const damConditions = INVALID_CHIP_DAM_NAMES.map(name => `${alias}.chipDamName != '${name}'`).join(' AND ');
|
||||
return `${alias}.chipSireName = '${VALID_CHIP_SIRE_NAME}' AND (${alias}.chipDamName IS NULL OR (${damConditions}))`;
|
||||
}
|
||||
73
backend/src/common/config/InbreedingConfig.ts
Normal file
73
backend/src/common/config/InbreedingConfig.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 근친도 관련 설정 상수
|
||||
*
|
||||
* @description
|
||||
* Wright's Coefficient of Inbreeding 알고리즘 기반 근친도 계산 및 위험도 판정 기준
|
||||
*
|
||||
* @source PRD 기능요구사항20.md SFR-COW-016-3
|
||||
* @reference Wright, S. (1922). Coefficients of Inbreeding and Relationship
|
||||
*/
|
||||
export const INBREEDING_CONFIG = {
|
||||
/**
|
||||
* 위험도 판정 기준 (%)
|
||||
* - 정상: < 15%
|
||||
* - 주의: 15-20%
|
||||
* - 위험: > 20%
|
||||
*/
|
||||
RISK_LEVELS: {
|
||||
NORMAL_MAX: 15, // 정상 상한선 (< 15%)
|
||||
WARNING_MIN: 15, // 주의 하한선 (>= 15%)
|
||||
WARNING_MAX: 20, // 주의 상한선 (<= 20%)
|
||||
DANGER_MIN: 20, // 위험 하한선 (> 20%)
|
||||
},
|
||||
|
||||
/**
|
||||
* 다세대 시뮬레이션 위험도 판정 기준 (%)
|
||||
* - 정상: < 6.25%
|
||||
* - 주의: 6.25% ~ 임계값
|
||||
* - 위험: > 임계값
|
||||
*/
|
||||
MULTI_GENERATION_RISK_LEVELS: {
|
||||
SAFE_MAX: 6.25, // 안전 상한선 (< 6.25%)
|
||||
// WARNING: 6.25% ~ inbreedingThreshold (사용자 지정)
|
||||
// DANGER: > inbreedingThreshold (사용자 지정)
|
||||
},
|
||||
|
||||
/**
|
||||
* 기본 근친도 임계값 (%)
|
||||
* Wright's Coefficient 기준 안전 임계값
|
||||
*/
|
||||
DEFAULT_THRESHOLD: 12.5,
|
||||
|
||||
/**
|
||||
* 세대별 근친도 영향 감소율
|
||||
* - 1세대: 100% 영향
|
||||
* - 2세대: 50% 영향 (1/2)
|
||||
* - 3세대: 25% 영향 (1/4)
|
||||
* - 4세대: 12.5% 영향 (1/8)
|
||||
*/
|
||||
GENERATION_DECAY: {
|
||||
GEN_1: 1.0, // 100%
|
||||
GEN_2: 0.5, // 50%
|
||||
GEN_3: 0.25, // 25%
|
||||
GEN_4: 0.125, // 12.5%
|
||||
GEN_5: 0.0625, // 6.25%
|
||||
},
|
||||
|
||||
/**
|
||||
* KPN 순환 전략 설정
|
||||
*/
|
||||
ROTATION_STRATEGY: {
|
||||
CYCLE_GENERATIONS: 2, // N세대마다 순환 (기본값: 2세대)
|
||||
},
|
||||
|
||||
/**
|
||||
* 유리형 비율 평가 기준 (%)
|
||||
*/
|
||||
FAVORABLE_RATE_THRESHOLDS: {
|
||||
EXCELLENT: 75, // 매우 우수 (>= 75%)
|
||||
GOOD: 60, // 양호 (>= 60%)
|
||||
AVERAGE: 50, // 보통 (>= 50%)
|
||||
POOR: 70, // 권장사항 생성 기준 (>= 70%)
|
||||
},
|
||||
} as const;
|
||||
90
backend/src/common/config/MptNormalRanges.ts
Normal file
90
backend/src/common/config/MptNormalRanges.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* MPT 혈액대사검사 정상 범위 기준값
|
||||
*
|
||||
* @description
|
||||
* 각 MPT 항목별 권장 정상 범위를 정의합니다.
|
||||
* 이 범위 내에 있으면 "우수" 판정을 받습니다.
|
||||
*
|
||||
* @export
|
||||
* @constant
|
||||
*/
|
||||
export const MPT_NORMAL_RANGES = {
|
||||
/**
|
||||
* 알부민 (Albumin)
|
||||
* 단위: g/dL
|
||||
*/
|
||||
albumin: { min: 3.3, max: 4.3 },
|
||||
|
||||
/**
|
||||
* 총 글로불린 (Total Globulin)
|
||||
* 단위: g/L
|
||||
*/
|
||||
totalGlobulin: { min: 9.1, max: 36.1 },
|
||||
|
||||
/**
|
||||
* A/G 비율 (Albumin/Globulin Ratio)
|
||||
* 단위: 비율
|
||||
*/
|
||||
agRatio: { min: 0.1, max: 0.4 },
|
||||
|
||||
/**
|
||||
* 혈중요소질소 (Blood Urea Nitrogen)
|
||||
* 단위: mg/dL
|
||||
*/
|
||||
bun: { min: 11.7, max: 18.9 },
|
||||
|
||||
/**
|
||||
* AST (Aspartate Aminotransferase)
|
||||
* 단위: U/L
|
||||
*/
|
||||
ast: { min: 47, max: 92 },
|
||||
|
||||
/**
|
||||
* GGT (Gamma-Glutamyl Transferase)
|
||||
* 단위: U/L
|
||||
*/
|
||||
ggt: { min: 11, max: 32 },
|
||||
|
||||
/**
|
||||
* 지방간 지수 (Fatty Liver Index)
|
||||
* 단위: 지수
|
||||
*/
|
||||
fattyLiverIndex: { min: -1.2, max: 9.9 },
|
||||
|
||||
/**
|
||||
* 칼슘 (Calcium)
|
||||
* 단위: mg/dL
|
||||
*/
|
||||
calcium: { min: 8.1, max: 10.6 },
|
||||
|
||||
/**
|
||||
* 인 (Phosphorus)
|
||||
* 단위: mg/dL
|
||||
*/
|
||||
phosphorus: { min: 6.2, max: 8.9 },
|
||||
|
||||
/**
|
||||
* Ca/P 비율 (Calcium/Phosphorus Ratio)
|
||||
* 단위: 비율
|
||||
*/
|
||||
caPRatio: { min: 1.2, max: 1.3 },
|
||||
|
||||
/**
|
||||
* 마그네슘 (Magnesium)
|
||||
* 단위: mg/dL
|
||||
*/
|
||||
magnesium: { min: 1.6, max: 3.3 },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* MPT 항목 타입
|
||||
*/
|
||||
export type MptCriteriaKey = keyof typeof MPT_NORMAL_RANGES;
|
||||
|
||||
/**
|
||||
* MPT 범위 타입
|
||||
*/
|
||||
export interface MptRange {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
62
backend/src/common/config/PaginationConfig.ts
Normal file
62
backend/src/common/config/PaginationConfig.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 페이징 설정 상수
|
||||
*
|
||||
* @description
|
||||
* 목록 조회, 검색, 가상 스크롤링 등의 페이징 기본값
|
||||
*/
|
||||
export const PAGINATION_CONFIG = {
|
||||
/**
|
||||
* 목록별 기본 페이지당 항목 수
|
||||
*/
|
||||
DEFAULTS: {
|
||||
/**
|
||||
* 개체 목록
|
||||
* 일반 개체 목록 조회 시 기본값
|
||||
*/
|
||||
COW_LIST: 10,
|
||||
|
||||
/**
|
||||
* 교배조합 목록
|
||||
*/
|
||||
BREED_SAVE: 10,
|
||||
|
||||
/**
|
||||
* 검색 결과
|
||||
* 키워드 검색 결과 표시 기본값
|
||||
*/
|
||||
SEARCH_RESULT: 20,
|
||||
|
||||
/**
|
||||
* 유전자 검색
|
||||
* 5000개 이상 유전자 검색 시 기본값
|
||||
*/
|
||||
GENE_SEARCH: 50,
|
||||
|
||||
/**
|
||||
* 가상 스크롤링
|
||||
* 무한 스크롤 방식 로딩 단위
|
||||
*/
|
||||
VIRTUAL_SCROLL: 50,
|
||||
},
|
||||
|
||||
/**
|
||||
* 제한값
|
||||
*/
|
||||
LIMITS: {
|
||||
/**
|
||||
* 최소 페이지당 항목 수
|
||||
*/
|
||||
MIN: 1,
|
||||
|
||||
/**
|
||||
* 최대 페이지당 항목 수
|
||||
* 성능 및 메모리 고려
|
||||
*/
|
||||
MAX: 100,
|
||||
|
||||
/**
|
||||
* 기본 페이지 번호
|
||||
*/
|
||||
DEFAULT_PAGE: 1,
|
||||
},
|
||||
} as const;
|
||||
105
backend/src/common/config/RecommendationConfig.ts
Normal file
105
backend/src/common/config/RecommendationConfig.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 추천 시스템 설정 상수
|
||||
*
|
||||
* @description
|
||||
* KPN 추천, 개체 추천, 패키지 추천 등 추천 시스템 관련 설정값
|
||||
*
|
||||
* @source PRD 기능요구사항20.md SFR-COW-016, SFR-COW-037
|
||||
*/
|
||||
export const RECOMMENDATION_CONFIG = {
|
||||
/**
|
||||
* 유전자 매칭 점수 관련
|
||||
*/
|
||||
GENE_SCORE: {
|
||||
/**
|
||||
* 점수 차이 임계값
|
||||
* 유전자 매칭 점수 차이가 이 값보다 작으면 근친도를 우선 고려
|
||||
*/
|
||||
DIFF_THRESHOLD: 5,
|
||||
},
|
||||
|
||||
/**
|
||||
* 기본값
|
||||
*/
|
||||
DEFAULTS: {
|
||||
/**
|
||||
* 근친도 임계값 (%)
|
||||
* Wright's Coefficient 기준
|
||||
*/
|
||||
INBREEDING_THRESHOLD: 12.5,
|
||||
|
||||
/**
|
||||
* 추천 개수
|
||||
* 상위 N개의 KPN/개체를 추천
|
||||
*/
|
||||
RECOMMENDATION_LIMIT: 10,
|
||||
|
||||
/**
|
||||
* 세대제약 기준
|
||||
* 최근 N세대 이내 사용된 KPN을 추천에서 제외
|
||||
*/
|
||||
GENERATION_THRESHOLD: 3,
|
||||
},
|
||||
|
||||
/**
|
||||
* KPN 패키지 설정
|
||||
*/
|
||||
PACKAGE: {
|
||||
/**
|
||||
* 기본 패키지 크기
|
||||
* 추천할 KPN 세트 개수
|
||||
*/
|
||||
DEFAULT_SIZE: 5,
|
||||
|
||||
/**
|
||||
* 최소 패키지 크기
|
||||
*/
|
||||
MIN_SIZE: 3,
|
||||
|
||||
/**
|
||||
* 최대 패키지 크기
|
||||
*/
|
||||
MAX_SIZE: 10,
|
||||
},
|
||||
|
||||
/**
|
||||
* 커버리지 기준 (%)
|
||||
* 유전자 목표 달성률 평가 기준
|
||||
*/
|
||||
COVERAGE: {
|
||||
/**
|
||||
* 우수 기준
|
||||
* 50% 이상 커버리지
|
||||
*/
|
||||
EXCELLENT: 50,
|
||||
|
||||
/**
|
||||
* 양호 기준
|
||||
* 30% 이상 커버리지
|
||||
*/
|
||||
GOOD: 30,
|
||||
|
||||
/**
|
||||
* 최소 기준
|
||||
* 20% 이상 커버리지
|
||||
*/
|
||||
MINIMUM: 20,
|
||||
},
|
||||
|
||||
/**
|
||||
* KPN 순환 전략
|
||||
*/
|
||||
ROTATION: {
|
||||
/**
|
||||
* 최소 KPN 개수
|
||||
* 순환 전략 적용 최소 개수
|
||||
*/
|
||||
MIN_KPN_COUNT: 3,
|
||||
|
||||
/**
|
||||
* 재사용 안전 세대
|
||||
* 동일 KPN을 이 세대 이후에 재사용 가능
|
||||
*/
|
||||
SAFE_REUSE_GENERATION: 4,
|
||||
},
|
||||
} as const;
|
||||
36
backend/src/common/config/VerificationConfig.ts
Normal file
36
backend/src/common/config/VerificationConfig.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 인증 관련 설정 (이메일)
|
||||
*
|
||||
* @description
|
||||
* 이메일 인증, 비밀번호 재설정 등의 인증 관련 상수 값 정의
|
||||
*/
|
||||
export const VERIFICATION_CONFIG = {
|
||||
/**
|
||||
* 인증 코드 만료 시간 (초)
|
||||
* 인증시간 3분 = 180초
|
||||
*/
|
||||
CODE_EXPIRY_SECONDS: 180,
|
||||
|
||||
/**
|
||||
* 비밀번호 재설정 토큰 만료 시간 (초)
|
||||
* 30분 = 1800초
|
||||
*/
|
||||
RESET_TOKEN_EXPIRY_SECONDS: 1800,
|
||||
|
||||
/**
|
||||
* 랜덤 토큰 생성을 위한 바이트 길이
|
||||
* 32바이트 = 64자의 16진수 문자열
|
||||
*/
|
||||
TOKEN_BYTES_LENGTH: 32,
|
||||
|
||||
/**
|
||||
* 인증 코드 길이 (6자리 숫자)
|
||||
*/
|
||||
CODE_LENGTH: 6,
|
||||
|
||||
/**
|
||||
* 인증 코드 재전송 대기 시간 (초)
|
||||
* 1분 = 60초
|
||||
*/
|
||||
RESEND_DELAY_SECONDS: 60,
|
||||
} as const;
|
||||
6
backend/src/common/const/AccountStatusType.ts
Normal file
6
backend/src/common/const/AccountStatusType.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// 계정 상태 Enum
|
||||
export enum AccountStatusType {
|
||||
ACTIVE = "ACTIVE", // 정상
|
||||
INACTIVE = "INACTIVE", // 비활성
|
||||
SUSPENDED = "SUSPENDED", // 정지
|
||||
}
|
||||
5
backend/src/common/const/AnimalType.ts
Normal file
5
backend/src/common/const/AnimalType.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// 개체 타입 Enum
|
||||
export enum AnimalType {
|
||||
COW = 'COW', // 개체
|
||||
KPN = 'KPN', // KPN
|
||||
}
|
||||
12
backend/src/common/const/AnlysStatType.ts
Normal file
12
backend/src/common/const/AnlysStatType.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 분석 현황 상태 값 Enum
|
||||
*
|
||||
* @export
|
||||
* @enum {number}
|
||||
*/
|
||||
export enum AnlysStatType {
|
||||
MATCH = '친자일치',
|
||||
MISMATCH = '친자불일치',
|
||||
IMPOSSIBLE = '분석불가',
|
||||
NO_HISTORY = '이력제부재',
|
||||
}
|
||||
13
backend/src/common/const/BreedingRecommendationType.ts
Normal file
13
backend/src/common/const/BreedingRecommendationType.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 사육/도태 추천 타입 Enum
|
||||
*
|
||||
* @export
|
||||
* @enum {string}
|
||||
*/
|
||||
export enum BreedingRecommendationType {
|
||||
/** 사육 추천 */
|
||||
BREED = '사육추천',
|
||||
|
||||
/** 도태 추천 */
|
||||
CULL = '도태추천',
|
||||
}
|
||||
7
backend/src/common/const/CowReproType.ts
Normal file
7
backend/src/common/const/CowReproType.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// 개체 번식 타입 Enum
|
||||
export enum CowReproType {
|
||||
DONOR = "공란우",
|
||||
RECIPIENT = "수란우",
|
||||
AI = "인공수정",
|
||||
CULL = "도태대상",
|
||||
}
|
||||
7
backend/src/common/const/CowStatusType.ts
Normal file
7
backend/src/common/const/CowStatusType.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// 개체 상태 Enum
|
||||
export enum CowStatusType {
|
||||
NORMAL = "정상",
|
||||
DEAD = "폐사",
|
||||
SLAUGHTER = "도축",
|
||||
SALE = "매각",
|
||||
}
|
||||
55
backend/src/common/const/FileType.ts
Normal file
55
backend/src/common/const/FileType.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 파일 타입 Enum
|
||||
*
|
||||
* @description
|
||||
* 엑셀 업로드 시 지원되는 파일 유형을 정의합니다.
|
||||
* 각 파일 유형별로 고유한 파싱 로직과 대상 테이블이 매핑됩니다.
|
||||
*
|
||||
* @reference SFR-ADMIN-001 (기능요구사항20.md)
|
||||
*
|
||||
* 파일 유형별 매핑:
|
||||
* - COW: 개체(암소) 정보 → tb_cow
|
||||
* - GENE: 유전자(SNP) 데이터 → tb_snp_cow
|
||||
* - GENOME: 유전체(유전능력) 데이터 → tb_genome_cow
|
||||
* - MPT: 혈액대사검사(MPT) (1행: 카테고리, 2행: 항목명, 3행~: 데이터) → tb_repro_mpt, tb_repro_mpt_item
|
||||
* - FERTILITY: 수태율 데이터 → tb_fertility_rate
|
||||
* - KPN_GENE: KPN 유전자 데이터 → tb_kpn_snp
|
||||
* - KPN_GENOME: KPN 유전체 데이터 → tb_kpn_genome
|
||||
* - KPN_MPT: KPN 혈액대사검사 → tb_kpn_mpt
|
||||
* - REGION_COW: 지역 개체 정보 → tb_region_cow
|
||||
* - REGION_GENE: 지역 유전자 데이터 → tb_region_snp
|
||||
* - REGION_GENOME: 지역 유전체 데이터 → tb_region_genome
|
||||
* - REGION_MPT: 지역 혈액대사검사 → tb_region_mpt
|
||||
* - HELP: 도움말 데이터 (유전자/유전체/번식능력 설명) → tb_help_content
|
||||
* - MARKER: 마커(유전자) 정보 (마커명, 관련형질, 목표유전자형 등) → tb_marker
|
||||
* 목표유전자형(target_genotype): KPN 추천 시 각 유전자의 우량형 기준 (AA, GG, CC 등)
|
||||
*/
|
||||
export enum FileType {
|
||||
// 개체(암소) 데이터
|
||||
COW = '개체',
|
||||
|
||||
// 유전 데이터
|
||||
GENE = '유전자',
|
||||
GENOME = '유전체',
|
||||
|
||||
// 번식 데이터
|
||||
MPT = '혈액대사검사',
|
||||
FERTILITY = '수태율',
|
||||
|
||||
// KPN 데이터
|
||||
KPN_GENE = 'KPN유전자',
|
||||
KPN_GENOME = 'KPN유전체',
|
||||
KPN_MPT = 'KPN혈액대사검사',
|
||||
|
||||
// 지역 개체 데이터 (보은군 비교용)
|
||||
REGION_COW = '지역개체',
|
||||
REGION_GENE = '지역유전자',
|
||||
REGION_GENOME = '지역유전체',
|
||||
REGION_MPT = '지역혈액대사검사',
|
||||
|
||||
// 도움말 데이터
|
||||
HELP = '도움말',
|
||||
|
||||
// 마커(유전자) 정보
|
||||
MARKER = '마커정보',
|
||||
}
|
||||
11
backend/src/common/const/UserSeType.ts
Normal file
11
backend/src/common/const/UserSeType.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 사용자 타입 구분 Enum
|
||||
*
|
||||
* @export
|
||||
* @enum {number}
|
||||
*/
|
||||
export enum UserSeType {
|
||||
FARM = 'FARM', // 농가
|
||||
CNSLT = 'CNSLT', // 컨설턴트
|
||||
ORGAN = 'ORGAN', // 기관담당자
|
||||
}
|
||||
41
backend/src/common/decorators/current-user.decorator.ts
Normal file
41
backend/src/common/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* 현재 로그인한 사용자 정보를 가져오는 Decorator
|
||||
*
|
||||
* @description
|
||||
* 인증 미들웨어(JWT, Passport 등)가 req.user에 추가한 사용자 정보를 추출합니다.
|
||||
* 인증되지 않은 경우 기본값을 반환합니다.
|
||||
*
|
||||
* @example
|
||||
* // 전체 user 객체 가져오기
|
||||
* async method(@CurrentUser() user: any) {
|
||||
* console.log(user.userId, user.email);
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // 특정 속성만 가져오기
|
||||
* async method(@CurrentUser('userId') userId: string) {
|
||||
* console.log(userId); // 'user123' or 'system'
|
||||
* }
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: string | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
// 사용자 정보가 없으면 기본값 반환
|
||||
if (!user) {
|
||||
// userId를 요청한 경우 'system' 반환
|
||||
if (data === 'userId') {
|
||||
return 'system';
|
||||
}
|
||||
// 전체 user 객체를 요청한 경우 null 반환
|
||||
return null;
|
||||
}
|
||||
|
||||
// 특정 속성을 요청한 경우 해당 속성 반환
|
||||
// 전체 user 객체를 요청한 경우 user 반환
|
||||
return data ? user[data] : user;
|
||||
},
|
||||
);
|
||||
29
backend/src/common/decorators/public.decorator.ts
Normal file
29
backend/src/common/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Public 데코레이터
|
||||
*
|
||||
* @description
|
||||
* 인증이 필요 없는 공개 엔드포인트를 표시하는 데코레이터입니다.
|
||||
* JwtAuthGuard와 함께 사용하여 특정 엔드포인트의 인증을 건너뜁니다.
|
||||
*
|
||||
* @example
|
||||
* // 로그인, 회원가입 등 인증 없이 접근 가능한 엔드포인트
|
||||
* @Public()
|
||||
* @Post('login')
|
||||
* async login(@Body() loginDto: LoginDto) {
|
||||
* return this.authService.login(loginDto);
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // 전체 컨트롤러를 공개로 설정
|
||||
* @Public()
|
||||
* @Controller('public')
|
||||
* export class PublicController {
|
||||
* // 모든 엔드포인트가 인증 없이 접근 가능
|
||||
* }
|
||||
*
|
||||
* @export
|
||||
* @constant Public
|
||||
*/
|
||||
export const Public = () => SetMetadata('isPublic', true);
|
||||
37
backend/src/common/decorators/user.decorator.ts
Normal file
37
backend/src/common/decorators/user.decorator.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* User 데코레이터
|
||||
*
|
||||
* @description
|
||||
* JWT 인증 후 Request 객체에서 사용자 정보를 추출하는 데코레이터입니다.
|
||||
* @Req() req 대신 사용하여 더 간결하게 사용자 정보를 가져올 수 있습니다.
|
||||
*
|
||||
* @example
|
||||
* // 전체 사용자 정보 가져오기
|
||||
* @Get('profile')
|
||||
* @UseGuards(JwtAuthGuard)
|
||||
* getProfile(@User() user: any) {
|
||||
* return user; // { userId: '...', userNo: 1, role: 'user' }
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // 특정 필드만 가져오기
|
||||
* @Get('my-data')
|
||||
* @UseGuards(JwtAuthGuard)
|
||||
* getMyData(@User('userId') userId: string) {
|
||||
* return `Your ID is ${userId}`;
|
||||
* }
|
||||
*
|
||||
* @export
|
||||
* @constant User
|
||||
*/
|
||||
export const User = createParamDecorator(
|
||||
(data: string, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
// 특정 필드만 반환
|
||||
return data ? user?.[data] : user;
|
||||
},
|
||||
);
|
||||
87
backend/src/common/entities/base.entity.ts
Normal file
87
backend/src/common/entities/base.entity.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Column, CreateDateColumn, DeleteDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* TypeORM Entity
|
||||
*
|
||||
* @export
|
||||
* @abstract
|
||||
* @class BaseModel
|
||||
* @typedef {BaseModel}
|
||||
*/
|
||||
export abstract class BaseModel {
|
||||
/**
|
||||
* 모든 테이블의 기본 키 (Primary Key)
|
||||
*/
|
||||
// @PrimaryGeneratedColumn()
|
||||
// no: number;
|
||||
|
||||
/**
|
||||
* 데이터가 처음 생성된 시간
|
||||
*/
|
||||
@CreateDateColumn({
|
||||
name: 'reg_dt',
|
||||
type: 'timestamp',
|
||||
nullable: false,
|
||||
default: () => 'NOW()',
|
||||
comment: '등록일시',
|
||||
})
|
||||
regDt: Date;
|
||||
|
||||
/**
|
||||
*데이터가 마지막으로 업데이트된 시간
|
||||
*/
|
||||
@UpdateDateColumn({
|
||||
name: 'updt_dt',
|
||||
type: 'timestamp',
|
||||
nullable: true,
|
||||
comment: '수정일시',
|
||||
})
|
||||
updtDt: Date;
|
||||
|
||||
/**
|
||||
* Soft Delete 삭제일시 (NULL이면 활성 데이터)
|
||||
*/
|
||||
@DeleteDateColumn({
|
||||
name: 'del_dt',
|
||||
type: 'timestamp',
|
||||
nullable: true,
|
||||
comment: '삭제일시 (Soft Delete, NULL=활성)',
|
||||
})
|
||||
delDt: Date;
|
||||
|
||||
@Column({
|
||||
name: 'reg_ip',
|
||||
type: 'varchar',
|
||||
length: 50,
|
||||
nullable: true,
|
||||
comment: '등록 IP',
|
||||
})
|
||||
regIp: string;
|
||||
|
||||
@Column({
|
||||
name: 'reg_user_id',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
comment: '등록자 ID',
|
||||
})
|
||||
regUserId: string;
|
||||
|
||||
@Column({
|
||||
name: 'updt_ip',
|
||||
type: 'varchar',
|
||||
length: 50,
|
||||
nullable: true,
|
||||
comment: '수정 IP',
|
||||
})
|
||||
updtIp: string;
|
||||
|
||||
@Column({
|
||||
name: 'updt_user_id',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
comment: '수정자 ID',
|
||||
})
|
||||
updtUserId: string;
|
||||
}
|
||||
80
backend/src/common/filters/all-exceptions.filter.ts
Normal file
80
backend/src/common/filters/all-exceptions.filter.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* 모든 예외 필터
|
||||
*
|
||||
* @description
|
||||
* HTTP 예외뿐만 아니라 모든 예외를 잡아서 처리합니다.
|
||||
* 예상치 못한 에러도 일관된 형식으로 응답합니다.
|
||||
*
|
||||
* @example
|
||||
* // main.ts에서 전역 적용
|
||||
* app.useGlobalFilters(new AllExceptionsFilter());
|
||||
*
|
||||
* @export
|
||||
* @class AllExceptionsFilter
|
||||
* @implements {ExceptionFilter}
|
||||
*/
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
let status: number;
|
||||
let message: string | string[];
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
// HTTP 예외 처리
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
message = exceptionResponse;
|
||||
} else if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
|
||||
message = (exceptionResponse as any).message || exception.message;
|
||||
} else {
|
||||
message = exception.message;
|
||||
}
|
||||
} else {
|
||||
// 예상치 못한 에러 처리
|
||||
status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
message = '서버 내부 오류가 발생했습니다.';
|
||||
|
||||
// 개발 환경에서는 실제 에러 메시지 표시
|
||||
if (process.env.NODE_ENV === 'development' && exception instanceof Error) {
|
||||
message = exception.message;
|
||||
}
|
||||
}
|
||||
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
statusCode: status,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
method: request.method,
|
||||
message: Array.isArray(message) ? message : [message],
|
||||
};
|
||||
|
||||
// 개발 환경에서는 스택 트레이스 포함
|
||||
if (process.env.NODE_ENV === 'development' && exception instanceof Error) {
|
||||
(errorResponse as any).stack = exception.stack;
|
||||
}
|
||||
|
||||
// 에러 로깅
|
||||
console.error(
|
||||
`[${errorResponse.timestamp}] ${request.method} ${request.url} - ${status}`,
|
||||
exception,
|
||||
);
|
||||
|
||||
response.status(status).json(errorResponse);
|
||||
}
|
||||
}
|
||||
93
backend/src/common/filters/http-exception.filter.ts
Normal file
93
backend/src/common/filters/http-exception.filter.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* HTTP 예외 필터
|
||||
*
|
||||
* @description
|
||||
* 모든 HTTP 예외를 잡아서 일관된 형식으로 응답을 반환합니다.
|
||||
*
|
||||
* @example
|
||||
* // main.ts에서 전역 적용
|
||||
* app.useGlobalFilters(new HttpExceptionFilter());
|
||||
*
|
||||
* @export
|
||||
* @class HttpExceptionFilter
|
||||
* @implements {ExceptionFilter}
|
||||
*/
|
||||
@Catch(HttpException)
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: HttpException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
// 에러 메시지 추출
|
||||
let message: string | string[];
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
message = exceptionResponse;
|
||||
} else if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
|
||||
message = (exceptionResponse as any).message || exception.message;
|
||||
} else {
|
||||
message = exception.message;
|
||||
}
|
||||
|
||||
// 일관된 에러 응답 형식
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
statusCode: status,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
method: request.method,
|
||||
message: Array.isArray(message) ? message : [message],
|
||||
error: this.getErrorName(status),
|
||||
};
|
||||
|
||||
// 개발 환경에서는 스택 트레이스 포함
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
(errorResponse as any).stack = exception.stack;
|
||||
}
|
||||
|
||||
// 로깅
|
||||
console.error(
|
||||
`[${errorResponse.timestamp}] ${request.method} ${request.url} - ${status}`,
|
||||
message,
|
||||
);
|
||||
|
||||
response.status(status).json(errorResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 상태 코드에 따른 에러 이름 반환
|
||||
*
|
||||
* @private
|
||||
* @param {number} status - HTTP 상태 코드
|
||||
* @returns {string}
|
||||
*/
|
||||
private getErrorName(status: number): string {
|
||||
switch (status) {
|
||||
case HttpStatus.BAD_REQUEST:
|
||||
return 'Bad Request';
|
||||
case HttpStatus.UNAUTHORIZED:
|
||||
return 'Unauthorized';
|
||||
case HttpStatus.FORBIDDEN:
|
||||
return 'Forbidden';
|
||||
case HttpStatus.NOT_FOUND:
|
||||
return 'Not Found';
|
||||
case HttpStatus.CONFLICT:
|
||||
return 'Conflict';
|
||||
case HttpStatus.INTERNAL_SERVER_ERROR:
|
||||
return 'Internal Server Error';
|
||||
default:
|
||||
return 'Error';
|
||||
}
|
||||
}
|
||||
}
|
||||
78
backend/src/common/guards/jwt-auth.guard.ts
Normal file
78
backend/src/common/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
|
||||
/**
|
||||
* JWT 인증 가드
|
||||
*
|
||||
* @description
|
||||
* JWT 토큰 검증 가드입니다.
|
||||
* @Public() 데코레이터가 있는 엔드포인트는 인증을 건너뜁니다.
|
||||
*
|
||||
* @example
|
||||
* // 사용법 1: 특정 엔드포인트에 적용
|
||||
* @UseGuards(JwtAuthGuard)
|
||||
* @Get('profile')
|
||||
* getProfile(@Req() req) {
|
||||
* return req.user;
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // 사용법 2: 전역 적용 (main.ts)
|
||||
* app.useGlobalGuards(new JwtAuthGuard(new Reflector()));
|
||||
*
|
||||
* @example
|
||||
* // 사용법 3: 인증 제외
|
||||
* @Public()
|
||||
* @Get('public')
|
||||
* getPublicData() {
|
||||
* return 'Anyone can access';
|
||||
* }
|
||||
*
|
||||
* @export
|
||||
* @class JwtAuthGuard
|
||||
* @extends {AuthGuard('jwt')}
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 확인
|
||||
*
|
||||
* @param {ExecutionContext} context - 실행 컨텍스트
|
||||
* @returns {boolean | Promise<boolean>}
|
||||
*/
|
||||
canActivate(context: ExecutionContext) {
|
||||
// @Public() 데코레이터 확인
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
// Public 엔드포인트는 인증 건너뜀
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// JWT 검증 수행
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 처리
|
||||
*
|
||||
* @param {any} err - 에러
|
||||
* @param {any} user - 사용자 정보
|
||||
* @param {any} info - 추가 정보
|
||||
* @returns {any}
|
||||
*/
|
||||
handleRequest(err: any, user: any, info: any) {
|
||||
if (err || !user) {
|
||||
throw err || new UnauthorizedException('인증이 필요합니다. 로그인 후 이용해주세요.');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
54
backend/src/common/interceptors/logging.interceptor.ts
Normal file
54
backend/src/common/interceptors/logging.interceptor.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { Request } from 'express';
|
||||
|
||||
/**
|
||||
* 로깅 인터셉터
|
||||
*
|
||||
* @description
|
||||
* API 요청/응답을 로깅하고 실행 시간을 측정합니다.
|
||||
*
|
||||
* @example
|
||||
* // main.ts에서 전역 적용
|
||||
* app.useGlobalInterceptors(new LoggingInterceptor());
|
||||
*
|
||||
* @export
|
||||
* @class LoggingInterceptor
|
||||
* @implements {NestInterceptor}
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const { method, url, ip } = request;
|
||||
const userAgent = request.get('user-agent') || '';
|
||||
const now = Date.now();
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] Incoming Request: ${method} ${url} - ${ip} - ${userAgent}`,
|
||||
);
|
||||
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: (data) => {
|
||||
const responseTime = Date.now() - now;
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] Response: ${method} ${url} - ${responseTime}ms`,
|
||||
);
|
||||
},
|
||||
error: (error) => {
|
||||
const responseTime = Date.now() - now;
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] Error Response: ${method} ${url} - ${responseTime}ms - ${error.message}`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
56
backend/src/common/interceptors/transform.interceptor.ts
Normal file
56
backend/src/common/interceptors/transform.interceptor.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* 응답 인터페이스 (이메일)
|
||||
*/
|
||||
export interface Response<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답 변환 인터셉터
|
||||
*
|
||||
* @description
|
||||
* 모든 API 응답을 일관된 형식으로 변환합니다.
|
||||
* { success: true, data: ..., timestamp: ... } 형태로 래핑합니다.
|
||||
*
|
||||
* @example
|
||||
* // main.ts에서 전역 적용
|
||||
* app.useGlobalInterceptors(new TransformInterceptor());
|
||||
*
|
||||
* @example
|
||||
* // 원래 응답: { name: "홍길동" }
|
||||
* // 변환 후: { success: true, data: { name: "홍길동" }, timestamp: "2024-01-01T00:00:00.000Z" }
|
||||
* TransformInterceptor는 모든 응답을 { success, data, timestamp } 구조로 감싼다.
|
||||
* response.data.data.verified → true
|
||||
*
|
||||
* @export
|
||||
* @class TransformInterceptor
|
||||
* @implements {NestInterceptor<T, Response<T>>}
|
||||
*/
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T>
|
||||
implements NestInterceptor<T, Response<T>>
|
||||
{
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<Response<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
success: true,
|
||||
data, //여기에 return 데이터 객체 전체 응답
|
||||
timestamp: new Date().toISOString(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
23
backend/src/common/jwt/jwt.module.ts
Normal file
23
backend/src/common/jwt/jwt.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule as NestJwtModule, JwtModuleOptions } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
NestJwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService): JwtModuleOptions => ({
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get<string>('JWT_EXPIRES_IN') || '24h',
|
||||
} as any,
|
||||
global: true,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
exports: [NestJwtModule, PassportModule],
|
||||
})
|
||||
export class JwtModule {}
|
||||
45
backend/src/common/jwt/jwt.strategy.ts
Normal file
45
backend/src/common/jwt/jwt.strategy.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
/**
|
||||
* JWT 전략
|
||||
*
|
||||
* @description
|
||||
* JWT 토큰을 검증하고 사용자 정보를 추출하는 전략입니다.
|
||||
* Bearer 토큰에서 JWT를 추출하여 검증합니다.
|
||||
*
|
||||
* @export
|
||||
* @class JwtStrategy
|
||||
* @extends {PassportStrategy(Strategy)}
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false, //false로 설정하면 토큰 만료 시 인증 실패
|
||||
secretOrKey: configService.get<string>('JWT_SECRET') || 'your-secret-key',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 페이로드 검증 및 사용자 정보 반환
|
||||
*
|
||||
* @param {any} payload - JWT 페이로드
|
||||
* @returns {any} 검증된 사용자 정보
|
||||
*/
|
||||
validate(payload: any) {
|
||||
if (!payload.userId) {
|
||||
throw new UnauthorizedException('유효하지 않은 토큰입니다.');
|
||||
}
|
||||
|
||||
// Request 객체의 user 속성에 저장됨
|
||||
return {
|
||||
userId: payload.userId,
|
||||
userNo: payload.userNo,
|
||||
role: payload.role || 'user',
|
||||
};
|
||||
}
|
||||
}
|
||||
52
backend/src/common/utils/get-client-ip.ts
Normal file
52
backend/src/common/utils/get-client-ip.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
/**
|
||||
* 클라이언트의 실제 IP 주소를 추출합니다.
|
||||
*
|
||||
* @description
|
||||
* Proxy, Load Balancer, CDN 뒤에 있어도 실제 클라이언트 IP를 정확하게 가져옵니다.
|
||||
* 다음 순서로 IP를 확인합니다:
|
||||
* 1. X-Forwarded-For 헤더 (Proxy/Load Balancer)
|
||||
* 2. X-Real-IP 헤더 (Nginx)
|
||||
* 3. req.ip (Express 기본)
|
||||
* 4. req.socket.remoteAddress (직접 연결)
|
||||
* 5. 'unknown' (IP를 찾을 수 없는 경우)
|
||||
*
|
||||
* @param req - Express Request 객체
|
||||
* @returns 클라이언트 IP 주소
|
||||
*
|
||||
* @example
|
||||
* const ip = getClientIp(req);
|
||||
* console.log(ip); // '203.123.45.67' or 'unknown'
|
||||
*/
|
||||
export function getClientIp(req: Request): string {
|
||||
// 1. X-Forwarded-For 헤더 확인 (Proxy/Load Balancer 환경)
|
||||
// 형식: "client IP, proxy1 IP, proxy2 IP"
|
||||
const forwardedFor = req.headers['x-forwarded-for'];
|
||||
if (forwardedFor) {
|
||||
// 배열이면 첫 번째 요소, 문자열이면 콤마로 split
|
||||
const ips = Array.isArray(forwardedFor)
|
||||
? forwardedFor[0]
|
||||
: forwardedFor.split(',')[0];
|
||||
return ips.trim();
|
||||
}
|
||||
|
||||
// 2. X-Real-IP 헤더 확인 (Nginx 환경)
|
||||
const realIp = req.headers['x-real-ip'];
|
||||
if (realIp && typeof realIp === 'string') {
|
||||
return realIp.trim();
|
||||
}
|
||||
|
||||
// 3. Express가 제공하는 req.ip
|
||||
if (req.ip) {
|
||||
return req.ip;
|
||||
}
|
||||
|
||||
// 4. Socket의 remoteAddress
|
||||
if (req.socket?.remoteAddress) {
|
||||
return req.socket.remoteAddress;
|
||||
}
|
||||
|
||||
// 5. IP를 찾을 수 없는 경우
|
||||
return 'unknown';
|
||||
}
|
||||
90
backend/src/cow/cow.controller.ts
Normal file
90
backend/src/cow/cow.controller.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* 개체(Cow) 컨트롤러
|
||||
* ============================================================
|
||||
*
|
||||
* 사용 페이지: 개체 목록 페이지 (/cow)
|
||||
*
|
||||
* 엔드포인트:
|
||||
* - GET /cow - 기본 개체 목록 조회
|
||||
* - GET /cow/:id - 개체 상세 조회
|
||||
* - POST /cow/ranking - 랭킹 적용 개체 목록 조회
|
||||
* - POST /cow/ranking/global - 전체 개체 랭킹 조회
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
|
||||
import { CowService } from './cow.service';
|
||||
import { CowModel } from './entities/cow.entity';
|
||||
import { RankingRequestDto } from './dto/ranking-request.dto';
|
||||
|
||||
@Controller('cow')
|
||||
export class CowController {
|
||||
constructor(private readonly cowService: CowService) {}
|
||||
|
||||
/**
|
||||
* GET /cow
|
||||
* 기본 개체 목록 조회
|
||||
*/
|
||||
@Get()
|
||||
findAll(@Query('farmId') farmId?: string) {
|
||||
if (farmId) {
|
||||
return this.cowService.findByFarmId(+farmId);
|
||||
}
|
||||
return this.cowService.findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /cow/ranking
|
||||
* 랭킹이 적용된 개체 목록 조회
|
||||
*
|
||||
* 사용 페이지: 개체 목록 페이지
|
||||
*/
|
||||
@Post('ranking')
|
||||
findAllWithRanking(@Body() rankingRequest: RankingRequestDto) {
|
||||
return this.cowService.findAllWithRanking(rankingRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /cow/ranking/global
|
||||
* 전체 개체 랭킹 조회 (모든 농장 포함)
|
||||
*
|
||||
* 사용 페이지: 대시보드 (농장 순위 비교)
|
||||
*/
|
||||
@Post('ranking/global')
|
||||
findAllWithGlobalRanking(@Body() rankingRequest: RankingRequestDto) {
|
||||
// farmNo 필터 없이 전체 개체 랭킹 조회
|
||||
const globalRequest = {
|
||||
...rankingRequest,
|
||||
filterOptions: {
|
||||
...rankingRequest.filterOptions,
|
||||
farmNo: undefined,
|
||||
},
|
||||
};
|
||||
return this.cowService.findAllWithRanking(globalRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /cow/:cowId
|
||||
* 개체 상세 조회 (cowId: 개체식별번호 KOR로 시작)
|
||||
*/
|
||||
@Get(':cowId')
|
||||
findOne(@Param('cowId') cowId: string) {
|
||||
return this.cowService.findByCowId(cowId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() data: Partial<CowModel>) {
|
||||
return this.cowService.create(data);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() data: Partial<CowModel>) {
|
||||
return this.cowService.update(+id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.cowService.remove(+id);
|
||||
}
|
||||
}
|
||||
37
backend/src/cow/cow.module.ts
Normal file
37
backend/src/cow/cow.module.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* 개체(Cow) 모듈
|
||||
* ============================================================
|
||||
*
|
||||
* 사용 페이지: 개체 목록 페이지 (/cow)
|
||||
*
|
||||
* 등록된 엔티티:
|
||||
* - CowModel: 개체 기본 정보
|
||||
* - GenomeRequestModel: 유전체 분석 의뢰
|
||||
* - GenomeTraitDetailModel: 유전체 형질 상세 (35개 형질)
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CowController } from './cow.controller';
|
||||
import { CowService } from './cow.service';
|
||||
import { CowModel } from './entities/cow.entity';
|
||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
||||
import { FilterEngineModule } from '../shared/filter/filter-engine.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
CowModel, // 개체 기본 정보 (tb_cow)
|
||||
GenomeRequestModel, // 유전체 분석 의뢰 (tb_genome_request)
|
||||
GenomeTraitDetailModel, // 유전체 형질 상세 (tb_genome_trait_detail)
|
||||
]),
|
||||
FilterEngineModule, // 필터 엔진 모듈
|
||||
],
|
||||
controllers: [CowController],
|
||||
providers: [CowService],
|
||||
exports: [CowService],
|
||||
})
|
||||
export class CowModule {}
|
||||
394
backend/src/cow/cow.service.ts
Normal file
394
backend/src/cow/cow.service.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* 개체(Cow) 서비스
|
||||
* ============================================================
|
||||
*
|
||||
* 사용 페이지: 개체 목록 페이지 (/cow)
|
||||
*
|
||||
* 주요 기능:
|
||||
* 1. 기본 개체 목록 조회 (findAll, findByFarmId)
|
||||
* 2. 개체 단건 조회 (findOne, findByCowId)
|
||||
* 3. 랭킹 적용 개체 목록 조회 (findAllWithRanking)
|
||||
* - GENOME: 35개 형질 EBV 가중 평균
|
||||
* 4. 개체 CRUD (create, update, remove)
|
||||
* ============================================================
|
||||
*/
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull } from 'typeorm';
|
||||
import { CowModel } from './entities/cow.entity';
|
||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
||||
import { FilterEngineService } from '../shared/filter/filter-engine.service';
|
||||
import {
|
||||
RankingRequestDto,
|
||||
RankingCriteriaType,
|
||||
TraitRankingCondition,
|
||||
} from './dto/ranking-request.dto';
|
||||
import { isValidGenomeAnalysis } from '../common/config/GenomeAnalysisConfig';
|
||||
|
||||
/**
|
||||
* 개체(소) 관리 서비스
|
||||
*
|
||||
* 담당 기능:
|
||||
* - 개체 CRUD 작업
|
||||
* - 유전체 기반 랭킹 계산
|
||||
* - 필터링 및 정렬
|
||||
*/
|
||||
@Injectable()
|
||||
export class CowService {
|
||||
constructor(
|
||||
// 개체(소) 테이블 Repository
|
||||
@InjectRepository(CowModel)
|
||||
private readonly cowRepository: Repository<CowModel>,
|
||||
|
||||
// 유전체 분석 의뢰 Repository (형질 데이터 접근용)
|
||||
@InjectRepository(GenomeRequestModel)
|
||||
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
|
||||
|
||||
// 유전체 형질 상세 Repository (EBV 값 접근용)
|
||||
@InjectRepository(GenomeTraitDetailModel)
|
||||
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
|
||||
|
||||
// 동적 필터링 서비스 (검색, 정렬, 페이지네이션)
|
||||
private readonly filterEngineService: FilterEngineService,
|
||||
) { }
|
||||
|
||||
// ============================================================
|
||||
// 기본 조회 메서드
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 전체 개체 목록 조회
|
||||
*
|
||||
* @returns 삭제되지 않은 모든 개체 목록
|
||||
* - farm 관계 데이터 포함
|
||||
* - 등록일(regDt) 기준 내림차순 정렬 (최신순)
|
||||
*/
|
||||
async findAll(): Promise<CowModel[]> {
|
||||
return this.cowRepository.find({
|
||||
where: { delDt: IsNull() }, // 삭제되지 않은 데이터만
|
||||
relations: ['farm'], // 농장 정보 JOIN
|
||||
order: { regDt: 'DESC' }, // 최신순 정렬
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장별 개체 목록 조회
|
||||
*
|
||||
* @param farmNo - 농장 PK 번호
|
||||
* @returns 해당 농장의 모든 개체 목록 (최신순)
|
||||
*/
|
||||
async findByFarmId(farmNo: number): Promise<CowModel[]> {
|
||||
return this.cowRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
relations: ['farm'],
|
||||
order: { regDt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체 PK로 단건 조회
|
||||
*
|
||||
* @param id - 개체 PK 번호 (pkCowNo)
|
||||
* @returns 개체 정보 (farm 포함)
|
||||
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||
*/
|
||||
async findOne(id: number): Promise<CowModel> {
|
||||
const cow = await this.cowRepository.findOne({
|
||||
where: { pkCowNo: id, delDt: IsNull() },
|
||||
relations: ['farm'],
|
||||
});
|
||||
if (!cow) {
|
||||
throw new NotFoundException(`Cow #${id} not found`);
|
||||
}
|
||||
return cow;
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체식별번호(cowId)로 단건 조회
|
||||
*
|
||||
* @param cowId - 개체식별번호 (예: KOR002119144049)
|
||||
* @returns 개체 정보 (farm 포함)
|
||||
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||
*/
|
||||
async findByCowId(cowId: string): Promise<CowModel> {
|
||||
const cow = await this.cowRepository.findOne({
|
||||
where: { cowId: cowId, delDt: IsNull() },
|
||||
relations: ['farm'],
|
||||
});
|
||||
if (!cow) {
|
||||
throw new NotFoundException(`Cow with cowId ${cowId} not found`);
|
||||
}
|
||||
return cow;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 랭킹 적용 조회 메서드
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 랭킹 적용 개체 목록 조회 (메인 API)
|
||||
*
|
||||
* POST /cow/ranking 에서 호출
|
||||
*
|
||||
* 기능:
|
||||
* 1. 필터 조건으로 개체 목록 조회
|
||||
* 2. 랭킹 기준(GENOME/GENE)에 따라 점수 계산
|
||||
* 3. 점수 기준 정렬 후 순위 부여
|
||||
*
|
||||
* @param rankingRequest - 필터 옵션 + 랭킹 옵션
|
||||
* @returns 순위가 적용된 개체 목록
|
||||
*/
|
||||
async findAllWithRanking(rankingRequest: RankingRequestDto): Promise<any> {
|
||||
// Step 1: 요청에서 필터 옵션과 랭킹 옵션 추출
|
||||
const { filterOptions, rankingOptions } = rankingRequest;
|
||||
const { criteriaType } = rankingOptions;
|
||||
|
||||
// Step 2: 필터 조건에 맞는 개체 목록 조회
|
||||
const cows = await this.getFilteredCows(filterOptions);
|
||||
|
||||
// Step 3: 랭킹 기준에 따라 분기 처리
|
||||
switch (criteriaType) {
|
||||
// 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
|
||||
case RankingCriteriaType.GENOME:
|
||||
return this.applyGenomeRanking(cows, rankingOptions.traitConditions || []);
|
||||
|
||||
// 기본값: 랭킹 없이 순서대로 반환
|
||||
default:
|
||||
return {
|
||||
items: cows.map((cow, index) => ({
|
||||
entity: cow,
|
||||
rank: index + 1,
|
||||
sortValue: 0,
|
||||
})),
|
||||
total: cows.length,
|
||||
criteriaType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 조건에 맞는 개체 목록 조회 (Private)
|
||||
*
|
||||
* @param filterOptions - 필터/정렬/페이지네이션 옵션
|
||||
* @returns 필터링된 개체 목록
|
||||
*/
|
||||
private async getFilteredCows(filterOptions?: any): Promise<CowModel[]> {
|
||||
// QueryBuilder로 기본 쿼리 구성
|
||||
const queryBuilder = this.cowRepository
|
||||
.createQueryBuilder('cow')
|
||||
.leftJoinAndSelect('cow.farm', 'farm') // 농장 정보 JOIN
|
||||
.where('cow.delDt IS NULL'); // 삭제되지 않은 데이터만
|
||||
|
||||
// farmNo가 직접 전달된 경우 처리 (프론트엔드 호환성)
|
||||
if (filterOptions?.farmNo) {
|
||||
queryBuilder.andWhere('cow.fkFarmNo = :farmNo', {
|
||||
farmNo: filterOptions.farmNo
|
||||
});
|
||||
}
|
||||
|
||||
// FilterEngine 사용하여 동적 필터 적용
|
||||
if (filterOptions?.filters) {
|
||||
const result = await this.filterEngineService.executeFilteredQuery(
|
||||
queryBuilder,
|
||||
filterOptions,
|
||||
);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// 필터 없으면 전체 조회 (최신순)
|
||||
return queryBuilder.orderBy('cow.regDt', 'DESC').getMany();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 유전체(GENOME) 랭킹 메서드
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 유전체 형질 기반 랭킹 적용 (Private)
|
||||
*
|
||||
* 계산 방식: 선택한 형질들의 EBV 가중 평균
|
||||
* - 각 형질에 weight(가중치) 적용 가능
|
||||
* - 모든 선택 형질이 있어야 점수 계산
|
||||
*
|
||||
* @param cows - 필터링된 개체 목록
|
||||
* @param traitConditions - 형질별 가중치 조건 배열
|
||||
* @returns 순위가 적용된 개체 목록
|
||||
* @example
|
||||
* traitConditions = [
|
||||
* { traitNm: '도체중', weight: 8 },
|
||||
* { traitNm: '근내지방도', weight: 10 }
|
||||
* ]
|
||||
*/
|
||||
private async applyGenomeRanking(
|
||||
cows: CowModel[],
|
||||
traitConditions: TraitRankingCondition[],
|
||||
): Promise<any> {
|
||||
// 각 개체별로 점수 계산
|
||||
const cowsWithScore = await Promise.all(
|
||||
cows.map(async (cow) => {
|
||||
// Step 1: cowId로 직접 형질 상세 데이터 조회 (35개 형질의 EBV 값)
|
||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||
});
|
||||
|
||||
// 형질 데이터가 없으면 점수 null
|
||||
if (traitDetails.length === 0) {
|
||||
return { entity: cow, sortValue: null, details: [] };
|
||||
}
|
||||
|
||||
// Step 2: 해당 개체의 최신 유전체 분석 의뢰 조회 (친자감별 확인용)
|
||||
const latestRequest = await this.genomeRequestRepository.findOne({
|
||||
where: { fkCowNo: cow.pkCowNo, delDt: IsNull() },
|
||||
order: { requestDt: 'DESC', regDt: 'DESC' },
|
||||
});
|
||||
|
||||
// Step 3: 친자감별 확인 - 아비 KPN "일치"가 아니면 분석 불가
|
||||
if (!latestRequest || !isValidGenomeAnalysis(latestRequest.chipSireName, latestRequest.chipDamName, cow.cowId)) {
|
||||
return { entity: cow, sortValue: null, details: [] };
|
||||
}
|
||||
|
||||
// Step 4: 가중 평균 계산
|
||||
let weightedSum = 0; // 가중치 적용된 EBV 합계
|
||||
let totalWeight = 0; // 총 가중치
|
||||
let hasAllTraits = true; // 모든 선택 형질 존재 여부
|
||||
const details: any[] = []; // 계산 상세 내역
|
||||
|
||||
// 사용자가 선택한 각 형질에 대해 처리
|
||||
for (const condition of traitConditions) {
|
||||
// 형질명으로 해당 형질 데이터 찾기
|
||||
const trait = traitDetails.find((d) => d.traitName === condition.traitNm);
|
||||
const weight = condition.weight || 1; // 가중치 (기본값: 1)
|
||||
|
||||
if (trait && trait.traitEbv !== null) {
|
||||
// EBV 값이 있으면 가중치 적용하여 합산
|
||||
const ebv = Number(trait.traitEbv);
|
||||
weightedSum += ebv * weight; // EBV × 가중치
|
||||
totalWeight += weight; // 가중치 누적
|
||||
|
||||
// 상세 내역 저장 (응답용)
|
||||
details.push({
|
||||
code: condition.traitNm, // 형질명
|
||||
value: ebv, // EBV 값
|
||||
weight, // 적용된 가중치
|
||||
});
|
||||
} else {
|
||||
// 형질이 없으면 플래그 설정
|
||||
hasAllTraits = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: 최종 점수 계산 (가중 평균)
|
||||
// 모든 선택 형질이 있어야만 점수 계산
|
||||
const sortValue = (hasAllTraits && totalWeight > 0)
|
||||
? weightedSum / totalWeight // 가중 평균 = 가중합 / 총가중치
|
||||
: null;
|
||||
|
||||
// Step 7: 응답 데이터 구성
|
||||
return {
|
||||
entity: {
|
||||
...cow,
|
||||
anlysDt: latestRequest.requestDt, // 분석일자 추가
|
||||
},
|
||||
sortValue, // 계산된 종합 점수 (선발지수)
|
||||
details, // 점수 계산에 사용된 형질별 상세
|
||||
ranking: {
|
||||
requestNo: latestRequest.pkRequestNo, // 분석 의뢰 번호
|
||||
requestDt: latestRequest.requestDt, // 분석 의뢰일
|
||||
traits: traitDetails.map((d) => ({ // 전체 형질 데이터
|
||||
traitName: d.traitName, // 형질명
|
||||
traitVal: d.traitVal, // 실측값
|
||||
traitEbv: d.traitEbv, // EBV (표준화육종가)
|
||||
traitPercentile: d.traitPercentile, // 백분위
|
||||
})),
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 백엔드 응답 예시
|
||||
// ========================================
|
||||
// {
|
||||
// "items": [
|
||||
// {
|
||||
// "entity": { "cowId": "KOR123456", "cowNm": "뽀삐", ... },
|
||||
// "sortValue": 85.5, // 가중 평균 점수
|
||||
// "rank": 1, // 순위
|
||||
// "ranking": {
|
||||
// "requestNo": 100,
|
||||
// "traits": [
|
||||
// { "traitName": "도체중", "traitVal": 450, "traitEbv": 12.5 },
|
||||
// { "traitName": "등심단면적", "traitVal": 95, "traitEbv": 8.3 }
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
// ],
|
||||
// "total": 100,
|
||||
// "criteriaType": "GENOME"
|
||||
// }
|
||||
|
||||
// Step 8: 점수 기준 내림차순 정렬
|
||||
const sorted = cowsWithScore.sort((a, b) => {
|
||||
// null 값은 맨 뒤로
|
||||
if (a.sortValue === null && b.sortValue === null) return 0;
|
||||
if (a.sortValue === null) return 1;
|
||||
if (b.sortValue === null) return -1;
|
||||
// 점수 높은 순 (내림차순)
|
||||
return b.sortValue - a.sortValue;
|
||||
});
|
||||
|
||||
// Step 9: 순위 부여 후 반환
|
||||
return {
|
||||
items: sorted.map((item, index) => ({
|
||||
...item,
|
||||
rank: index + 1, // 1부터 시작하는 순위
|
||||
})),
|
||||
total: sorted.length,
|
||||
criteriaType: RankingCriteriaType.GENOME,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CRUD 메서드
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 새로운 개체 생성
|
||||
*
|
||||
* @param data - 생성할 개체 데이터
|
||||
* @returns 생성된 개체 엔티티
|
||||
*/
|
||||
async create(data: Partial<CowModel>): Promise<CowModel> {
|
||||
const cow = this.cowRepository.create(data);
|
||||
return this.cowRepository.save(cow);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체 정보 수정
|
||||
*
|
||||
* @param id - 개체 PK 번호
|
||||
* @param data - 수정할 데이터
|
||||
* @returns 수정된 개체 엔티티
|
||||
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||
*/
|
||||
async update(id: number, data: Partial<CowModel>): Promise<CowModel> {
|
||||
await this.findOne(id); // 존재 여부 확인
|
||||
await this.cowRepository.update(id, data);
|
||||
return this.findOne(id); // 수정된 데이터 반환
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체 삭제 (Soft Delete)
|
||||
*
|
||||
* 실제 삭제가 아닌 delDt 컬럼에 삭제 시간 기록
|
||||
*
|
||||
* @param id - 개체 PK 번호
|
||||
* @throws NotFoundException - 개체를 찾을 수 없는 경우
|
||||
*/
|
||||
async remove(id: number): Promise<void> {
|
||||
const cow = await this.findOne(id); // 존재 여부 확인
|
||||
await this.cowRepository.softRemove(cow);
|
||||
}
|
||||
}
|
||||
129
backend/src/cow/dto/ranking-request.dto.ts
Normal file
129
backend/src/cow/dto/ranking-request.dto.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* 랭킹 요청 DTO
|
||||
* ============================================================
|
||||
*
|
||||
* 사용 페이지: 개체 목록 페이지 (/cow)
|
||||
*
|
||||
* 프론트에서 POST /cow/ranking 호출 시 사용
|
||||
*
|
||||
* 지원하는 랭킹 기준:
|
||||
* 1. GENOME - 35개 유전체 형질 EBV 가중치 기반
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
/**
|
||||
* 랭킹 기준 타입
|
||||
* - GENOME: 유전체 형질 기반 랭킹 (35개 형질 EBV 가중 평균)
|
||||
*/
|
||||
export enum RankingCriteriaType {
|
||||
GENOME = 'GENOME',
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 필터 관련 타입 (FilterEngine에서 사용)
|
||||
// ============================================================
|
||||
|
||||
export type FilterOperator =
|
||||
| 'eq' // 같음
|
||||
| 'ne' // 같지 않음
|
||||
| 'gt' // 초과
|
||||
| 'gte' // 이상
|
||||
| 'lt' // 미만
|
||||
| 'lte' // 이하
|
||||
| 'like' // 포함 (문자열)
|
||||
| 'in' // 배열 내 포함
|
||||
| 'between'; // 범위
|
||||
|
||||
export type SortOrder = 'ASC' | 'DESC';
|
||||
|
||||
/**
|
||||
* 필터 조건
|
||||
* 예: { field: 'cowSex', operator: 'eq', value: 'F' }
|
||||
*/
|
||||
export interface FilterCondition {
|
||||
field: string;
|
||||
operator: FilterOperator;
|
||||
value: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정렬 옵션
|
||||
*/
|
||||
export interface SortOption {
|
||||
field: string;
|
||||
order: SortOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지네이션 옵션
|
||||
*/
|
||||
export interface PaginationOption {
|
||||
page: number; // 페이지 번호 (1부터 시작)
|
||||
limit: number; // 페이지당 개수
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 엔진 옵션
|
||||
* - 개체 목록 필터링에 사용
|
||||
*/
|
||||
export interface FilterEngineOptions {
|
||||
filters?: FilterCondition[];
|
||||
sorts?: SortOption[];
|
||||
pagination?: PaginationOption;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 랭킹 조건 타입
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 유전체 형질 랭킹 조건
|
||||
* - 35개 형질 중 사용자가 선택한 형질만 대상
|
||||
* - weight: 1~10 가중치 (10이 100%)
|
||||
*
|
||||
* 예: { traitNm: '도체중', weight: 8 }
|
||||
*/
|
||||
export interface TraitRankingCondition {
|
||||
traitNm: string; // 형질명 (예: '도체중', '근내지방도')
|
||||
weight?: number; // 가중치 1~10 (기본값: 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 랭킹 옵션
|
||||
*/
|
||||
export interface RankingOptions {
|
||||
criteriaType: RankingCriteriaType; // 랭킹 기준 타입
|
||||
traitConditions?: TraitRankingCondition[]; // GENOME용: 형질별 가중치
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 메인 요청 DTO
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 랭킹 요청 DTO
|
||||
*
|
||||
* 프론트에서 POST /cow/ranking 호출 시 Body로 전송
|
||||
*
|
||||
* @example
|
||||
* {
|
||||
* filterOptions: {
|
||||
* filters: [{ field: 'cowSex', operator: 'eq', value: 'F' }],
|
||||
* pagination: { page: 1, limit: 20 }
|
||||
* },
|
||||
* rankingOptions: {
|
||||
* criteriaType: 'GENOME',
|
||||
* traitConditions: [
|
||||
* { traitNm: '도체중', weight: 8 },
|
||||
* { traitNm: '근내지방도', weight: 10 }
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export interface RankingRequestDto {
|
||||
filterOptions?: FilterEngineOptions; // 필터/정렬/페이지네이션
|
||||
rankingOptions: RankingOptions; // 랭킹 조건
|
||||
}
|
||||
89
backend/src/cow/entities/cow.entity.ts
Normal file
89
backend/src/cow/entities/cow.entity.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { BaseModel } from 'src/common/entities/base.entity';
|
||||
import { FarmModel } from 'src/farm/entities/farm.entity';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* 개체 기본 정보 (tb_cow)
|
||||
* 암소/수소, 부모 혈통 포함
|
||||
*/
|
||||
@Entity({ name: 'tb_cow' })
|
||||
export class CowModel extends BaseModel {
|
||||
@PrimaryGeneratedColumn({
|
||||
name: 'pk_cow_no',
|
||||
type: 'int',
|
||||
comment: '내부 PK (자동증가)',
|
||||
})
|
||||
pkCowNo: number;
|
||||
|
||||
@Column({
|
||||
name: 'cow_id',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
nullable: true,
|
||||
comment: '개체식별번호 (KOR 또는 KPN)',
|
||||
})
|
||||
cowId: string;
|
||||
|
||||
@Column({
|
||||
name: 'cow_sex',
|
||||
type: 'varchar',
|
||||
length: 1,
|
||||
nullable: true,
|
||||
comment: '성별 (M/F)',
|
||||
})
|
||||
cowSex: string;
|
||||
|
||||
@Column({
|
||||
name: 'cow_birth_dt',
|
||||
type: 'date',
|
||||
nullable: true,
|
||||
comment: '생년월일',
|
||||
})
|
||||
cowBirthDt: Date;
|
||||
|
||||
@Column({
|
||||
name: 'sire_kpn',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
nullable: true,
|
||||
comment: '부(씨수소) KPN번호',
|
||||
})
|
||||
sireKpn: string;
|
||||
|
||||
@Column({
|
||||
name: 'dam_cow_id',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
nullable: true,
|
||||
comment: '모(어미소) 개체식별번호 (KOR)',
|
||||
})
|
||||
damCowId: string;
|
||||
|
||||
@Column({
|
||||
name: 'fk_farm_no',
|
||||
type: 'int',
|
||||
nullable: true,
|
||||
comment: '농장번호 FK',
|
||||
})
|
||||
fkFarmNo: number;
|
||||
|
||||
@Column({
|
||||
name: 'cow_status',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
nullable: true,
|
||||
comment: '개체상태',
|
||||
})
|
||||
cowStatus: string;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => FarmModel, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'fk_farm_no' })
|
||||
farm: FarmModel;
|
||||
}
|
||||
150
backend/src/dashboard/dashboard.controller.ts
Normal file
150
backend/src/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { DashboardFilterDto } from './dto/dashboard-filter.dto';
|
||||
|
||||
@Controller('dashboard')
|
||||
export class DashboardController {
|
||||
constructor(private readonly dashboardService: DashboardService) {}
|
||||
|
||||
/**
|
||||
* GET /dashboard/summary/:farmNo - 농장 현황 요약
|
||||
*/
|
||||
@Get('summary/:farmNo')
|
||||
getFarmSummary(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getFarmSummary(+farmNo, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/analysis-completion/:farmNo - 분석 완료 현황
|
||||
*/
|
||||
@Get('analysis-completion/:farmNo')
|
||||
getAnalysisCompletion(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getAnalysisCompletion(+farmNo, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/evaluation/:farmNo - 농장 종합 평가
|
||||
*/
|
||||
@Get('evaluation/:farmNo')
|
||||
getFarmEvaluation(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getFarmEvaluation(+farmNo, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/region-comparison/:farmNo - 보은군 비교 분석
|
||||
*/
|
||||
@Get('region-comparison/:farmNo')
|
||||
getRegionComparison(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getRegionComparison(+farmNo, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/cow-distribution/:farmNo - 개체 분포 분석
|
||||
*/
|
||||
@Get('cow-distribution/:farmNo')
|
||||
getCowDistribution(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getCowDistribution(+farmNo, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/kpn-aggregation/:farmNo - KPN 추천 집계
|
||||
*/
|
||||
@Get('kpn-aggregation/:farmNo')
|
||||
getKpnRecommendationAggregation(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getKpnRecommendationAggregation(+farmNo, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/farm-kpn-inventory/:farmNo - 농장 보유 KPN 목록
|
||||
*/
|
||||
@Get('farm-kpn-inventory/:farmNo')
|
||||
getFarmKpnInventory(@Param('farmNo') farmNo: string) {
|
||||
return this.dashboardService.getFarmKpnInventory(+farmNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/analysis-years/:farmNo - 농장 분석 이력 연도 목록
|
||||
*/
|
||||
@Get('analysis-years/:farmNo')
|
||||
getAnalysisYears(@Param('farmNo') farmNo: string) {
|
||||
return this.dashboardService.getAnalysisYears(+farmNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/analysis-years/:farmNo/latest - 최신 분석 연도
|
||||
*/
|
||||
@Get('analysis-years/:farmNo/latest')
|
||||
getLatestAnalysisYear(@Param('farmNo') farmNo: string) {
|
||||
return this.dashboardService.getLatestAnalysisYear(+farmNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/year-comparison/:farmNo - 3개년 비교 분석
|
||||
*/
|
||||
@Get('year-comparison/:farmNo')
|
||||
getYearComparison(@Param('farmNo') farmNo: string) {
|
||||
return this.dashboardService.getYearComparison(+farmNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/repro-efficiency/:farmNo - 번식 효율성 분석
|
||||
*/
|
||||
@Get('repro-efficiency/:farmNo')
|
||||
getReproEfficiency(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getReproEfficiency(+farmNo, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/excellent-cows/:farmNo - 우수개체 추천
|
||||
*/
|
||||
@Get('excellent-cows/:farmNo')
|
||||
getExcellentCows(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getExcellentCows(+farmNo, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/cull-cows/:farmNo - 도태개체 추천
|
||||
*/
|
||||
@Get('cull-cows/:farmNo')
|
||||
getCullCows(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getCullCows(+farmNo, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /dashboard/cattle-ranking/:farmNo - 보은군 내 소 개별 순위
|
||||
*/
|
||||
@Get('cattle-ranking/:farmNo')
|
||||
getCattleRankingInRegion(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query() filter: DashboardFilterDto,
|
||||
) {
|
||||
return this.dashboardService.getCattleRankingInRegion(+farmNo, filter);
|
||||
}
|
||||
}
|
||||
23
backend/src/dashboard/dashboard.module.ts
Normal file
23
backend/src/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { CowModel } from '../cow/entities/cow.entity';
|
||||
import { FarmModel } from '../farm/entities/farm.entity';
|
||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
CowModel,
|
||||
FarmModel,
|
||||
GenomeRequestModel,
|
||||
GenomeTraitDetailModel,
|
||||
]),
|
||||
],
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
exports: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
548
backend/src/dashboard/dashboard.service.ts
Normal file
548
backend/src/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull } from 'typeorm';
|
||||
import { CowModel } from '../cow/entities/cow.entity';
|
||||
import { FarmModel } from '../farm/entities/farm.entity';
|
||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||
import { GenomeTraitDetailModel } from '../genome/entities/genome-trait-detail.entity';
|
||||
import { DashboardFilterDto } from './dto/dashboard-filter.dto';
|
||||
import { isValidGenomeAnalysis, VALID_CHIP_SIRE_NAME } from '../common/config/GenomeAnalysisConfig';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(
|
||||
@InjectRepository(CowModel)
|
||||
private readonly cowRepository: Repository<CowModel>,
|
||||
|
||||
@InjectRepository(FarmModel)
|
||||
private readonly farmRepository: Repository<FarmModel>,
|
||||
|
||||
@InjectRepository(GenomeRequestModel)
|
||||
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
|
||||
|
||||
@InjectRepository(GenomeTraitDetailModel)
|
||||
private readonly genomeTraitDetailRepository: Repository<GenomeTraitDetailModel>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 농장 현황 요약
|
||||
*/
|
||||
async getFarmSummary(farmNo: number, filter?: DashboardFilterDto) {
|
||||
// 농장 정보 조회
|
||||
const farm = await this.farmRepository.findOne({
|
||||
where: { pkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
// 농장 소 목록 조회
|
||||
const cows = await this.cowRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
const totalCowCount = cows.length;
|
||||
const maleCowCount = cows.filter(cow => cow.cowSex === 'M').length;
|
||||
const femaleCowCount = cows.filter(cow => cow.cowSex === 'F').length;
|
||||
|
||||
return {
|
||||
farmNo,
|
||||
farmName: farm?.farmerName || '농장',
|
||||
totalCowCount,
|
||||
maleCowCount,
|
||||
femaleCowCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 완료 현황
|
||||
*/
|
||||
async getAnalysisCompletion(farmNo: number, filter?: DashboardFilterDto) {
|
||||
// 농장의 모든 유전체 분석 의뢰 조회
|
||||
const requests = await this.genomeRequestRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
relations: ['cow'],
|
||||
});
|
||||
|
||||
const farmAnlysCnt = requests.length;
|
||||
const matchCnt = requests.filter(r => r.chipSireName === '일치').length;
|
||||
const failCnt = requests.filter(r => r.chipSireName && r.chipSireName !== '일치').length;
|
||||
const noHistCnt = requests.filter(r => !r.chipSireName).length;
|
||||
|
||||
return {
|
||||
farmAnlysCnt,
|
||||
matchCnt,
|
||||
failCnt,
|
||||
noHistCnt,
|
||||
paternities: requests.map(r => ({
|
||||
cowNo: r.fkCowNo,
|
||||
cowId: r.cow?.cowId,
|
||||
fatherMatch: r.chipSireName === '일치' ? '일치' : (r.chipSireName ? '불일치' : '미확인'),
|
||||
requestDt: r.requestDt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장 종합 평가
|
||||
*/
|
||||
async getFarmEvaluation(farmNo: number, filter?: DashboardFilterDto) {
|
||||
const cows = await this.cowRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
// 각 개체의 유전체 점수 계산
|
||||
const scores: number[] = [];
|
||||
|
||||
for (const cow of cows) {
|
||||
// cowId로 직접 형질 데이터 조회
|
||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||
});
|
||||
|
||||
if (traitDetails.length === 0) continue;
|
||||
|
||||
// 모든 형질의 EBV 평균 계산
|
||||
const ebvValues = traitDetails
|
||||
.filter(d => d.traitEbv !== null)
|
||||
.map(d => Number(d.traitEbv));
|
||||
|
||||
if (ebvValues.length > 0) {
|
||||
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
||||
scores.push(avgEbv);
|
||||
}
|
||||
}
|
||||
|
||||
const farmAverage = scores.length > 0
|
||||
? scores.reduce((sum, s) => sum + s, 0) / scores.length
|
||||
: 0;
|
||||
|
||||
// 등급 산정 (표준화육종가 기준)
|
||||
let grade = 'C';
|
||||
if (farmAverage >= 1.0) grade = 'A';
|
||||
else if (farmAverage >= 0.5) grade = 'B';
|
||||
else if (farmAverage >= -0.5) grade = 'C';
|
||||
else if (farmAverage >= -1.0) grade = 'D';
|
||||
else grade = 'E';
|
||||
|
||||
return {
|
||||
farmNo,
|
||||
farmAverage: Math.round(farmAverage * 100) / 100,
|
||||
grade,
|
||||
analyzedCount: scores.length,
|
||||
totalCount: cows.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 보은군 비교 분석
|
||||
*/
|
||||
async getRegionComparison(farmNo: number, filter?: DashboardFilterDto) {
|
||||
// 내 농장 평균 계산
|
||||
const farmEval = await this.getFarmEvaluation(farmNo, filter);
|
||||
|
||||
// 전체 농장 평균 계산 (보은군 대비)
|
||||
const allFarms = await this.farmRepository.find({
|
||||
where: { delDt: IsNull() },
|
||||
});
|
||||
|
||||
const farmScores: { farmNo: number; avgScore: number }[] = [];
|
||||
|
||||
for (const farm of allFarms) {
|
||||
const farmCows = await this.cowRepository.find({
|
||||
where: { fkFarmNo: farm.pkFarmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
const scores: number[] = [];
|
||||
for (const cow of farmCows) {
|
||||
// cowId로 직접 형질 데이터 조회
|
||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||
});
|
||||
|
||||
if (traitDetails.length === 0) continue;
|
||||
|
||||
const ebvValues = traitDetails
|
||||
.filter(d => d.traitEbv !== null)
|
||||
.map(d => Number(d.traitEbv));
|
||||
|
||||
if (ebvValues.length > 0) {
|
||||
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
||||
scores.push(avgEbv);
|
||||
}
|
||||
}
|
||||
|
||||
if (scores.length > 0) {
|
||||
farmScores.push({
|
||||
farmNo: farm.pkFarmNo,
|
||||
avgScore: scores.reduce((sum, s) => sum + s, 0) / scores.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 내 농장 순위 계산
|
||||
farmScores.sort((a, b) => b.avgScore - a.avgScore);
|
||||
const myFarmRank = farmScores.findIndex(f => f.farmNo === farmNo) + 1;
|
||||
const totalFarmCount = farmScores.length;
|
||||
const topPercent = totalFarmCount > 0 ? Math.round((myFarmRank / totalFarmCount) * 100) : 0;
|
||||
|
||||
// 지역 평균
|
||||
const regionAverage = farmScores.length > 0
|
||||
? farmScores.reduce((sum, f) => sum + f.avgScore, 0) / farmScores.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
farmNo,
|
||||
farmAverage: farmEval.farmAverage,
|
||||
regionAverage: Math.round(regionAverage * 100) / 100,
|
||||
farmRank: myFarmRank || 1,
|
||||
totalFarmCount: totalFarmCount || 1,
|
||||
topPercent: topPercent || 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 개체 분포 분석
|
||||
*/
|
||||
async getCowDistribution(farmNo: number, filter?: DashboardFilterDto) {
|
||||
const cows = await this.cowRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
const distribution = {
|
||||
A: 0,
|
||||
B: 0,
|
||||
C: 0,
|
||||
D: 0,
|
||||
E: 0,
|
||||
};
|
||||
|
||||
for (const cow of cows) {
|
||||
// cowId로 직접 형질 데이터 조회
|
||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||
});
|
||||
|
||||
if (traitDetails.length === 0) continue;
|
||||
|
||||
const ebvValues = traitDetails
|
||||
.filter(d => d.traitEbv !== null)
|
||||
.map(d => Number(d.traitEbv));
|
||||
|
||||
if (ebvValues.length > 0) {
|
||||
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
||||
|
||||
if (avgEbv >= 1.0) distribution.A++;
|
||||
else if (avgEbv >= 0.5) distribution.B++;
|
||||
else if (avgEbv >= -0.5) distribution.C++;
|
||||
else if (avgEbv >= -1.0) distribution.D++;
|
||||
else distribution.E++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
farmNo,
|
||||
distribution,
|
||||
total: cows.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* KPN 추천 집계
|
||||
*/
|
||||
async getKpnRecommendationAggregation(farmNo: number, filter?: DashboardFilterDto) {
|
||||
// 타겟 유전자 기반 KPN 추천 로직
|
||||
const targetGenes = filter?.targetGenes || [];
|
||||
|
||||
// 농장 소 목록 조회
|
||||
const cows = await this.cowRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
// 간단한 KPN 추천 집계 (실제 로직은 더 복잡할 수 있음)
|
||||
const kpnAggregations = [
|
||||
{
|
||||
kpnNumber: 'KPN001',
|
||||
kpnName: '한우왕',
|
||||
avgMatchingScore: 85.5,
|
||||
recommendedCowCount: Math.floor(cows.length * 0.3),
|
||||
percentage: 30,
|
||||
rank: 1,
|
||||
isOwned: false,
|
||||
sampleCowIds: cows.slice(0, 3).map(c => c.cowId),
|
||||
},
|
||||
{
|
||||
kpnNumber: 'KPN002',
|
||||
kpnName: '육량대왕',
|
||||
avgMatchingScore: 82.3,
|
||||
recommendedCowCount: Math.floor(cows.length * 0.25),
|
||||
percentage: 25,
|
||||
rank: 2,
|
||||
isOwned: true,
|
||||
sampleCowIds: cows.slice(3, 6).map(c => c.cowId),
|
||||
},
|
||||
{
|
||||
kpnNumber: 'KPN003',
|
||||
kpnName: '품질명가',
|
||||
avgMatchingScore: 79.1,
|
||||
recommendedCowCount: Math.floor(cows.length * 0.2),
|
||||
percentage: 20,
|
||||
rank: 3,
|
||||
isOwned: false,
|
||||
sampleCowIds: cows.slice(6, 9).map(c => c.cowId),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
farmNo,
|
||||
targetGenes,
|
||||
kpnAggregations,
|
||||
totalCows: cows.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 농장 보유 KPN 목록
|
||||
*/
|
||||
async getFarmKpnInventory(farmNo: number) {
|
||||
// 실제 구현에서는 별도의 KPN 보유 테이블을 조회
|
||||
return {
|
||||
farmNo,
|
||||
kpnList: [
|
||||
{ kpnNumber: 'KPN002', kpnName: '육량대왕', stockCount: 10 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 이력 연도 목록
|
||||
*/
|
||||
async getAnalysisYears(farmNo: number): Promise<number[]> {
|
||||
const requests = await this.genomeRequestRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
select: ['requestDt'],
|
||||
});
|
||||
|
||||
const years = new Set<number>();
|
||||
for (const req of requests) {
|
||||
if (req.requestDt) {
|
||||
years.add(new Date(req.requestDt).getFullYear());
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(years).sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
/**
|
||||
* 최신 분석 연도
|
||||
*/
|
||||
async getLatestAnalysisYear(farmNo: number): Promise<number> {
|
||||
const years = await this.getAnalysisYears(farmNo);
|
||||
return years[0] || new Date().getFullYear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 3개년 비교 분석
|
||||
*/
|
||||
async getYearComparison(farmNo: number) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years = [currentYear, currentYear - 1, currentYear - 2];
|
||||
|
||||
const comparison = [];
|
||||
for (const year of years) {
|
||||
// 해당 연도의 분석 데이터 집계
|
||||
const requests = await this.genomeRequestRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
const yearRequests = requests.filter(r => {
|
||||
if (!r.requestDt) return false;
|
||||
return new Date(r.requestDt).getFullYear() === year;
|
||||
});
|
||||
|
||||
comparison.push({
|
||||
year,
|
||||
analysisCount: yearRequests.length,
|
||||
matchCount: yearRequests.filter(r => r.chipSireName === '일치').length,
|
||||
});
|
||||
}
|
||||
|
||||
return { farmNo, comparison };
|
||||
}
|
||||
|
||||
/**
|
||||
* 번식 효율성 분석 (더미 데이터)
|
||||
*/
|
||||
async getReproEfficiency(farmNo: number, filter?: DashboardFilterDto) {
|
||||
return {
|
||||
farmNo,
|
||||
avgCalvingInterval: 12.5,
|
||||
avgFirstCalvingAge: 24,
|
||||
conceptionRate: 65.5,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 우수개체 추천
|
||||
*/
|
||||
async getExcellentCows(farmNo: number, filter?: DashboardFilterDto) {
|
||||
const limit = filter?.limit || 5;
|
||||
|
||||
const cows = await this.cowRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
const cowsWithScore: Array<{ cow: CowModel; score: number }> = [];
|
||||
|
||||
for (const cow of cows) {
|
||||
// cowId로 직접 형질 데이터 조회
|
||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||
});
|
||||
|
||||
if (traitDetails.length === 0) continue;
|
||||
|
||||
const ebvValues = traitDetails
|
||||
.filter(d => d.traitEbv !== null)
|
||||
.map(d => Number(d.traitEbv));
|
||||
|
||||
if (ebvValues.length > 0) {
|
||||
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
||||
cowsWithScore.push({ cow, score: avgEbv });
|
||||
}
|
||||
}
|
||||
|
||||
// 점수 내림차순 정렬
|
||||
cowsWithScore.sort((a, b) => b.score - a.score);
|
||||
|
||||
return {
|
||||
farmNo,
|
||||
excellentCows: cowsWithScore.slice(0, limit).map((item, index) => ({
|
||||
rank: index + 1,
|
||||
cowNo: item.cow.pkCowNo,
|
||||
cowId: item.cow.cowId,
|
||||
score: Math.round(item.score * 100) / 100,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 도태개체 추천
|
||||
*/
|
||||
async getCullCows(farmNo: number, filter?: DashboardFilterDto) {
|
||||
const limit = filter?.limit || 5;
|
||||
|
||||
const cows = await this.cowRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
const cowsWithScore: Array<{ cow: CowModel; score: number }> = [];
|
||||
|
||||
for (const cow of cows) {
|
||||
// cowId로 직접 형질 데이터 조회
|
||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||
});
|
||||
|
||||
if (traitDetails.length === 0) continue;
|
||||
|
||||
const ebvValues = traitDetails
|
||||
.filter(d => d.traitEbv !== null)
|
||||
.map(d => Number(d.traitEbv));
|
||||
|
||||
if (ebvValues.length > 0) {
|
||||
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
||||
cowsWithScore.push({ cow, score: avgEbv });
|
||||
}
|
||||
}
|
||||
|
||||
// 점수 오름차순 정렬 (낮은 점수가 도태 대상)
|
||||
cowsWithScore.sort((a, b) => a.score - b.score);
|
||||
|
||||
return {
|
||||
farmNo,
|
||||
cullCows: cowsWithScore.slice(0, limit).map((item, index) => ({
|
||||
rank: index + 1,
|
||||
cowNo: item.cow.pkCowNo,
|
||||
cowId: item.cow.cowId,
|
||||
score: Math.round(item.score * 100) / 100,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 보은군 내 소 개별 순위
|
||||
*/
|
||||
async getCattleRankingInRegion(farmNo: number, filter?: DashboardFilterDto) {
|
||||
// 전체 소 목록과 점수 계산
|
||||
const allCows = await this.cowRepository.find({
|
||||
where: { delDt: IsNull() },
|
||||
relations: ['farm'],
|
||||
});
|
||||
|
||||
const cowsWithScore: Array<{
|
||||
cow: CowModel;
|
||||
score: number;
|
||||
farmNo: number;
|
||||
}> = [];
|
||||
|
||||
for (const cow of allCows) {
|
||||
// cowId로 직접 형질 데이터 조회
|
||||
const traitDetails = await this.genomeTraitDetailRepository.find({
|
||||
where: { cowId: cow.cowId, delDt: IsNull() },
|
||||
});
|
||||
|
||||
if (traitDetails.length === 0) continue;
|
||||
|
||||
const ebvValues = traitDetails
|
||||
.filter(d => d.traitEbv !== null)
|
||||
.map(d => Number(d.traitEbv));
|
||||
|
||||
if (ebvValues.length > 0) {
|
||||
const avgEbv = ebvValues.reduce((sum, v) => sum + v, 0) / ebvValues.length;
|
||||
cowsWithScore.push({
|
||||
cow,
|
||||
score: avgEbv,
|
||||
farmNo: cow.fkFarmNo,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 점수 내림차순 정렬
|
||||
cowsWithScore.sort((a, b) => b.score - a.score);
|
||||
|
||||
// 순위 부여
|
||||
const rankedCows = cowsWithScore.map((item, index) => ({
|
||||
...item,
|
||||
rank: index + 1,
|
||||
percentile: Math.round(((index + 1) / cowsWithScore.length) * 100),
|
||||
}));
|
||||
|
||||
// 내 농장 소만 필터링
|
||||
const myFarmCows = rankedCows.filter(item => item.farmNo === farmNo);
|
||||
|
||||
const farm = await this.farmRepository.findOne({
|
||||
where: { pkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
return {
|
||||
farmNo,
|
||||
farmName: farm?.farmerName || '농장',
|
||||
regionName: farm?.regionSi || '보은군',
|
||||
totalCattle: cowsWithScore.length,
|
||||
farmCattleCount: myFarmCows.length,
|
||||
rankings: myFarmCows.map(item => ({
|
||||
cowNo: item.cow.cowId,
|
||||
cowName: `KOR ${item.cow.cowId}`,
|
||||
genomeScore: Math.round(item.score * 100) / 100,
|
||||
rank: item.rank,
|
||||
totalCattle: cowsWithScore.length,
|
||||
percentile: item.percentile,
|
||||
})),
|
||||
statistics: {
|
||||
bestRank: myFarmCows.length > 0 ? myFarmCows[0].rank : 0,
|
||||
averageRank: myFarmCows.length > 0
|
||||
? Math.round(myFarmCows.reduce((sum, c) => sum + c.rank, 0) / myFarmCows.length)
|
||||
: 0,
|
||||
topPercentCount: myFarmCows.filter(c => c.percentile <= 10).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
42
backend/src/dashboard/dto/dashboard-filter.dto.ts
Normal file
42
backend/src/dashboard/dto/dashboard-filter.dto.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { IsOptional, IsArray, IsNumber, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 대시보드 필터 DTO
|
||||
*/
|
||||
export class DashboardFilterDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
anlysStatus?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reproType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
geneGrades?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
genomeGrades?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
reproGrades?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
targetGenes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
minScore?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
regionNm?: string;
|
||||
}
|
||||
81
backend/src/farm/entities/farm.entity.ts
Normal file
81
backend/src/farm/entities/farm.entity.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { BaseModel } from 'src/common/entities/base.entity';
|
||||
import { UserModel } from 'src/user/entities/user.entity';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* 농장 정보 (tb_farm)
|
||||
* 1사용자 N농장 관계
|
||||
*/
|
||||
@Entity({ name: 'tb_farm' })
|
||||
export class FarmModel extends BaseModel {
|
||||
@PrimaryGeneratedColumn({
|
||||
name: 'pk_farm_no',
|
||||
type: 'int',
|
||||
comment: '내부 PK (자동증가)',
|
||||
})
|
||||
pkFarmNo: number;
|
||||
|
||||
@Column({
|
||||
name: 'trace_farm_no',
|
||||
type: 'varchar',
|
||||
length: 50,
|
||||
nullable: true,
|
||||
comment: '축평원 농장번호 (나중에 입력)',
|
||||
})
|
||||
traceFarmNo: string;
|
||||
|
||||
@Column({
|
||||
name: 'fk_user_no',
|
||||
type: 'int',
|
||||
nullable: true,
|
||||
comment: '사용자정보 FK',
|
||||
})
|
||||
fkUserNo: number;
|
||||
|
||||
@Column({
|
||||
name: 'farmer_name',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
comment: '농장주명 (농가명을 농장주로 사용)',
|
||||
})
|
||||
farmerName: string;
|
||||
|
||||
@Column({
|
||||
name: 'region_si',
|
||||
type: 'varchar',
|
||||
length: 50,
|
||||
nullable: true,
|
||||
comment: '시군',
|
||||
})
|
||||
regionSi: string;
|
||||
|
||||
@Column({
|
||||
name: 'region_gu',
|
||||
type: 'varchar',
|
||||
length: 50,
|
||||
nullable: true,
|
||||
comment: '시/군/구 (지역)',
|
||||
})
|
||||
regionGu: string;
|
||||
|
||||
@Column({
|
||||
name: 'road_address',
|
||||
type: 'varchar',
|
||||
length: 500,
|
||||
nullable: true,
|
||||
comment: '도로명 주소',
|
||||
})
|
||||
roadAddress: string;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => UserModel, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'fk_user_no' })
|
||||
user: UserModel;
|
||||
}
|
||||
52
backend/src/farm/farm.controller.ts
Normal file
52
backend/src/farm/farm.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
|
||||
import { FarmService } from './farm.service';
|
||||
import { FarmModel } from './entities/farm.entity';
|
||||
|
||||
@Controller('farm')
|
||||
export class FarmController {
|
||||
constructor(private readonly farmService: FarmService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Query('userId') userId?: string) {
|
||||
if (userId) {
|
||||
return this.farmService.findByUserId(+userId);
|
||||
}
|
||||
return this.farmService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.farmService.findOne(+id);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /farm/:farmNo/analysis-latest - 농장 최신 분석 의뢰 정보 조회
|
||||
*/
|
||||
@Get(':farmNo/analysis-latest')
|
||||
getLatestAnalysisRequest(@Param('farmNo') farmNo: string) {
|
||||
return this.farmService.getLatestAnalysisRequest(+farmNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /farm/:farmNo/analysis-all - 농장 전체 분석 의뢰 목록 조회
|
||||
*/
|
||||
@Get(':farmNo/analysis-all')
|
||||
getAllAnalysisRequests(@Param('farmNo') farmNo: string) {
|
||||
return this.farmService.getAllAnalysisRequests(+farmNo);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() data: Partial<FarmModel>) {
|
||||
return this.farmService.create(data);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() data: Partial<FarmModel>) {
|
||||
return this.farmService.update(+id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.farmService.remove(+id);
|
||||
}
|
||||
}
|
||||
21
backend/src/farm/farm.module.ts
Normal file
21
backend/src/farm/farm.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { FarmController } from './farm.controller';
|
||||
import { FarmService } from './farm.service';
|
||||
import { FarmModel } from './entities/farm.entity';
|
||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||
import { CowModel } from '../cow/entities/cow.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
FarmModel,
|
||||
GenomeRequestModel,
|
||||
CowModel,
|
||||
]),
|
||||
],
|
||||
controllers: [FarmController],
|
||||
providers: [FarmService],
|
||||
exports: [FarmService],
|
||||
})
|
||||
export class FarmModule {}
|
||||
128
backend/src/farm/farm.service.ts
Normal file
128
backend/src/farm/farm.service.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull } from 'typeorm';
|
||||
import { FarmModel } from './entities/farm.entity';
|
||||
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||
import { CowModel } from '../cow/entities/cow.entity';
|
||||
import { isValidGenomeAnalysis, VALID_CHIP_SIRE_NAME } from '../common/config/GenomeAnalysisConfig';
|
||||
|
||||
@Injectable()
|
||||
export class FarmService {
|
||||
constructor(
|
||||
@InjectRepository(FarmModel)
|
||||
private readonly farmRepository: Repository<FarmModel>,
|
||||
|
||||
@InjectRepository(GenomeRequestModel)
|
||||
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
|
||||
|
||||
@InjectRepository(CowModel)
|
||||
private readonly cowRepository: Repository<CowModel>,
|
||||
) { }
|
||||
|
||||
// 전체 농장 조회
|
||||
async findAll(): Promise<FarmModel[]> {
|
||||
return this.farmRepository.find({
|
||||
where: { delDt: IsNull() },
|
||||
relations: ['user'],
|
||||
order: { regDt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
// 사용자별 농장 조회
|
||||
async findByUserId(userNo: number): Promise<FarmModel[]> {
|
||||
return this.farmRepository.find({
|
||||
where: { fkUserNo: userNo, delDt: IsNull() },
|
||||
relations: ['user'],
|
||||
order: { regDt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
// 농장 단건 조회
|
||||
async findOne(id: number): Promise<FarmModel> {
|
||||
const farm = await this.farmRepository.findOne({
|
||||
where: { pkFarmNo: id, delDt: IsNull() },
|
||||
relations: ['user'],
|
||||
});
|
||||
if (!farm) {
|
||||
throw new NotFoundException('Farm #' + id + ' not found');
|
||||
}
|
||||
return farm;
|
||||
}
|
||||
|
||||
// 농장 생성
|
||||
async create(data: Partial<FarmModel>): Promise<FarmModel> {
|
||||
const farm = this.farmRepository.create(data);
|
||||
return this.farmRepository.save(farm);
|
||||
}
|
||||
|
||||
// 농장 수정
|
||||
async update(id: number, data: Partial<FarmModel>): Promise<FarmModel> {
|
||||
await this.findOne(id);
|
||||
await this.farmRepository.update(id, data);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
// 농장 삭제
|
||||
async remove(id: number): Promise<void> {
|
||||
const farm = await this.findOne(id);
|
||||
await this.farmRepository.softRemove(farm);
|
||||
}
|
||||
|
||||
// 농장 최신 분석 의뢰 정보 조회
|
||||
async getLatestAnalysisRequest(farmNo: number): Promise<any> {
|
||||
const farm = await this.findOne(farmNo);
|
||||
|
||||
const requests = await this.genomeRequestRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
relations: ['cow'],
|
||||
order: { requestDt: 'DESC' },
|
||||
});
|
||||
|
||||
const cows = await this.cowRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
});
|
||||
|
||||
const farmAnlysCnt = requests.length;
|
||||
const matchCnt = requests.filter(r => isValidGenomeAnalysis(r.chipSireName, r.chipDamName, r.cow?.cowId)).length;
|
||||
const failCnt = requests.filter(r => r.chipSireName && r.chipSireName !== '일치').length;
|
||||
const noHistCnt = requests.filter(r => !r.chipSireName).length;
|
||||
|
||||
return {
|
||||
pkFarmAnlysNo: 1,
|
||||
fkFarmNo: farmNo,
|
||||
farmAnlysNm: farm.farmerName,
|
||||
anlysReqDt: requests[0]?.requestDt || new Date(),
|
||||
region: farm.regionSi,
|
||||
city: farm.regionGu,
|
||||
anlysReqCnt: cows.length,
|
||||
farmAnlysCnt,
|
||||
matchCnt,
|
||||
mismatchCnt: failCnt,
|
||||
failCnt,
|
||||
noHistCnt,
|
||||
matchRate: farmAnlysCnt > 0 ? Math.round((matchCnt / farmAnlysCnt) * 100) : 0,
|
||||
msAnlysCnt: 0,
|
||||
anlysRmrk: '',
|
||||
paternities: requests.map(r => ({
|
||||
pkFarmPaternityNo: r.pkRequestNo,
|
||||
fkFarmAnlysNo: 1,
|
||||
receiptDate: r.requestDt,
|
||||
farmOwnerName: farm.farmerName,
|
||||
individualNo: r.cow?.cowId || '',
|
||||
kpnNo: r.cow?.sireKpn || '',
|
||||
motherIndividualNo: r.cow?.damCowId || '',
|
||||
hairRootQuality: r.sampleAmount || '',
|
||||
remarks: r.cowRemarks || '',
|
||||
fatherMatch: r.chipSireName === '일치' ? '일치' : (r.chipSireName ? '불일치' : '미확인'),
|
||||
motherMatch: r.chipDamName || '미확인',
|
||||
reportDate: r.chipReportDt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// 농장 전체 분석 의뢰 목록 조회
|
||||
async getAllAnalysisRequests(farmNo: number): Promise<any[]> {
|
||||
const latestRequest = await this.getLatestAnalysisRequest(farmNo);
|
||||
return [latestRequest];
|
||||
}
|
||||
}
|
||||
192
backend/src/genome/entities/genome-request.entity.ts
Normal file
192
backend/src/genome/entities/genome-request.entity.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { BaseModel } from 'src/common/entities/base.entity';
|
||||
import { CowModel } from 'src/cow/entities/cow.entity';
|
||||
import { FarmModel } from 'src/farm/entities/farm.entity';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* 유전체 분석 의뢰 (tb_genome_request)
|
||||
* 1개체 N의뢰 관계
|
||||
*/
|
||||
@Entity({ name: 'tb_genome_request' })
|
||||
export class GenomeRequestModel extends BaseModel {
|
||||
@PrimaryGeneratedColumn({
|
||||
name: 'pk_request_no',
|
||||
type: 'int',
|
||||
comment: 'No (PK)',
|
||||
})
|
||||
pkRequestNo: number;
|
||||
|
||||
@Column({
|
||||
name: 'fk_farm_no',
|
||||
type: 'int',
|
||||
nullable: true,
|
||||
comment: '농장번호 FK',
|
||||
})
|
||||
fkFarmNo: number;
|
||||
|
||||
@Column({
|
||||
name: 'fk_cow_no',
|
||||
type: 'int',
|
||||
nullable: true,
|
||||
comment: '개체번호 FK',
|
||||
})
|
||||
fkCowNo: number;
|
||||
|
||||
@Column({
|
||||
name: 'cow_remarks',
|
||||
type: 'varchar',
|
||||
length: 500,
|
||||
nullable: true,
|
||||
comment: '개체 비고',
|
||||
})
|
||||
cowRemarks: string;
|
||||
|
||||
@Column({
|
||||
name: 'request_dt',
|
||||
type: 'date',
|
||||
nullable: true,
|
||||
comment: '접수일자',
|
||||
})
|
||||
requestDt: Date;
|
||||
|
||||
@Column({
|
||||
name: 'snp_test',
|
||||
type: 'varchar',
|
||||
length: 10,
|
||||
nullable: true,
|
||||
comment: 'SNP 검사',
|
||||
})
|
||||
snpTest: string;
|
||||
|
||||
@Column({
|
||||
name: 'ms_test',
|
||||
type: 'varchar',
|
||||
length: 10,
|
||||
nullable: true,
|
||||
comment: 'MS 검사',
|
||||
})
|
||||
msTest: string;
|
||||
|
||||
@Column({
|
||||
name: 'sample_amount',
|
||||
type: 'varchar',
|
||||
length: 50,
|
||||
nullable: true,
|
||||
comment: '모근량',
|
||||
})
|
||||
sampleAmount: string;
|
||||
|
||||
@Column({
|
||||
name: 'sample_remarks',
|
||||
type: 'varchar',
|
||||
length: 500,
|
||||
nullable: true,
|
||||
comment: '모근 비고',
|
||||
})
|
||||
sampleRemarks: string;
|
||||
|
||||
// 칩 분석 정보
|
||||
@Column({
|
||||
name: 'chip_no',
|
||||
type: 'varchar',
|
||||
length: 50,
|
||||
nullable: true,
|
||||
comment: '분석 Chip 번호',
|
||||
})
|
||||
chipNo: string;
|
||||
|
||||
@Column({
|
||||
name: 'chip_type',
|
||||
type: 'varchar',
|
||||
length: 50,
|
||||
nullable: true,
|
||||
comment: '분석 칩 종류',
|
||||
})
|
||||
chipType: string;
|
||||
|
||||
@Column({
|
||||
name: 'chip_info',
|
||||
type: 'varchar',
|
||||
length: 200,
|
||||
nullable: true,
|
||||
comment: '칩정보',
|
||||
})
|
||||
chipInfo: string;
|
||||
|
||||
@Column({
|
||||
name: 'chip_remarks',
|
||||
type: 'varchar',
|
||||
length: 500,
|
||||
nullable: true,
|
||||
comment: '칩 비고',
|
||||
})
|
||||
chipRemarks: string;
|
||||
|
||||
@Column({
|
||||
name: 'chip_sire_name',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
comment: '칩분석 아비명',
|
||||
})
|
||||
chipSireName: string;
|
||||
|
||||
@Column({
|
||||
name: 'chip_dam_name',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
comment: '칩분석 어미명',
|
||||
})
|
||||
chipDamName: string;
|
||||
|
||||
@Column({
|
||||
name: 'chip_report_dt',
|
||||
type: 'date',
|
||||
nullable: true,
|
||||
comment: '칩분석 보고일자',
|
||||
})
|
||||
chipReportDt: Date;
|
||||
|
||||
// MS 검사 결과
|
||||
@Column({
|
||||
name: 'ms_result_status',
|
||||
type: 'varchar',
|
||||
length: 50,
|
||||
nullable: true,
|
||||
comment: 'MS 감정결과',
|
||||
})
|
||||
msResultStatus: string;
|
||||
|
||||
@Column({
|
||||
name: 'ms_father_estimate',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
comment: 'MS 추정부',
|
||||
})
|
||||
msFatherEstimate: string;
|
||||
|
||||
@Column({
|
||||
name: 'ms_report_dt',
|
||||
type: 'date',
|
||||
nullable: true,
|
||||
comment: 'MS 보고일자',
|
||||
})
|
||||
msReportDt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => CowModel, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'fk_cow_no' })
|
||||
cow: CowModel;
|
||||
|
||||
@ManyToOne(() => FarmModel, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'fk_farm_no' })
|
||||
farm: FarmModel;
|
||||
}
|
||||
88
backend/src/genome/entities/genome-trait-detail.entity.ts
Normal file
88
backend/src/genome/entities/genome-trait-detail.entity.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { BaseModel } from 'src/common/entities/base.entity';
|
||||
import { GenomeRequestModel } from './genome-request.entity';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* 유전체 형질 상세 정보 (tb_genome_trait_detail)
|
||||
* 1개체당 35개 형질 → 35개 행으로 저장
|
||||
*/
|
||||
@Entity({ name: 'tb_genome_trait_detail' })
|
||||
@Index('idx_genome_trait_cow_id', ['cowId'])
|
||||
@Index('idx_genome_trait_name', ['traitName'])
|
||||
@Index('idx_genome_trait_cow_trait', ['cowId', 'traitName'])
|
||||
export class GenomeTraitDetailModel extends BaseModel {
|
||||
@PrimaryGeneratedColumn({
|
||||
name: 'pk_trait_detail_no',
|
||||
type: 'int',
|
||||
comment: '형질상세번호 PK',
|
||||
})
|
||||
pkTraitDetailNo: number;
|
||||
|
||||
@Column({
|
||||
name: 'fk_request_no',
|
||||
type: 'int',
|
||||
nullable: true,
|
||||
comment: '의뢰번호 FK',
|
||||
})
|
||||
fkRequestNo: number;
|
||||
|
||||
@Column({
|
||||
name: 'cow_id',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
nullable: true,
|
||||
comment: '개체식별번호 (KOR)',
|
||||
})
|
||||
cowId: string;
|
||||
|
||||
@Column({
|
||||
name: 'trait_name',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
comment: '형질명 (예: "12개월령체중", "도체중", "등심단면적")',
|
||||
})
|
||||
traitName: string;
|
||||
|
||||
@Column({
|
||||
name: 'trait_val',
|
||||
type: 'decimal',
|
||||
precision: 15,
|
||||
scale: 6,
|
||||
nullable: true,
|
||||
comment: '실측값',
|
||||
})
|
||||
traitVal: number;
|
||||
|
||||
@Column({
|
||||
name: 'trait_ebv',
|
||||
type: 'decimal',
|
||||
precision: 15,
|
||||
scale: 6,
|
||||
nullable: true,
|
||||
comment: '표준화육종가 (EBV: Estimated Breeding Value)',
|
||||
})
|
||||
traitEbv: number;
|
||||
|
||||
@Column({
|
||||
name: 'trait_percentile',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 4,
|
||||
nullable: true,
|
||||
comment: '백분위수 (전국 대비 순위)',
|
||||
})
|
||||
traitPercentile: number;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => GenomeRequestModel, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'fk_request_no' })
|
||||
genomeRequest: GenomeRequestModel;
|
||||
}
|
||||
185
backend/src/genome/genome.controller.ts
Normal file
185
backend/src/genome/genome.controller.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { GenomeService } from './genome.service';
|
||||
import { GenomeRequestModel } from './entities/genome-request.entity';
|
||||
import { GenomeTraitDetailModel } from './entities/genome-trait-detail.entity';
|
||||
|
||||
export interface CategoryAverageDto {
|
||||
category: string;
|
||||
avgEbv: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ComparisonAveragesDto {
|
||||
nationwide: CategoryAverageDto[];
|
||||
region: CategoryAverageDto[];
|
||||
farm: CategoryAverageDto[];
|
||||
}
|
||||
|
||||
@Controller('genome')
|
||||
export class GenomeController {
|
||||
constructor(private readonly genomeService: GenomeService) { }
|
||||
|
||||
/**
|
||||
* GET /genome/dashboard-stats/:farmNo
|
||||
* 대시보드용 유전체 분석 통계 데이터
|
||||
* @param farmNo - 농장 번호
|
||||
*/
|
||||
@Get('dashboard-stats/:farmNo')
|
||||
getDashboardStats(@Param('farmNo') farmNo: string) {
|
||||
return this.genomeService.getDashboardStats(+farmNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /genome/farm-trait-comparison/:farmNo
|
||||
* 농가별 형질 비교 데이터 (농가 vs 지역 vs 전국)
|
||||
* @param farmNo - 농장 번호
|
||||
*/
|
||||
@Get('farm-trait-comparison/:farmNo')
|
||||
getFarmTraitComparison(@Param('farmNo') farmNo: string) {
|
||||
return this.genomeService.getFarmTraitComparison(+farmNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /genome/farm-region-ranking/:farmNo
|
||||
* 농가의 보은군 내 순위 조회 (대시보드용)
|
||||
* @param farmNo - 농장 번호
|
||||
*/
|
||||
@Get('farm-region-ranking/:farmNo')
|
||||
getFarmRegionRanking(@Param('farmNo') farmNo: string) {
|
||||
return this.genomeService.getFarmRegionRanking(+farmNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /genome/trait-rank/:cowId/:traitName
|
||||
* 개별 형질 기준 순위 조회
|
||||
* @param cowId - 개체식별번호 (KOR...)
|
||||
* @param traitName - 형질명 (도체중, 근내지방도 등)
|
||||
*/
|
||||
@Get('trait-rank/:cowId/:traitName')
|
||||
getTraitRank(
|
||||
@Param('cowId') cowId: string,
|
||||
@Param('traitName') traitName: string
|
||||
) {
|
||||
return this.genomeService.getTraitRank(cowId, traitName);
|
||||
}
|
||||
|
||||
// Genome Request endpoints
|
||||
@Get('request')
|
||||
findAllRequests(
|
||||
@Query('cowId') cowId?: string,
|
||||
@Query('farmId') farmId?: string,
|
||||
) {
|
||||
if (cowId) {
|
||||
return this.genomeService.findRequestsByCowId(+cowId);
|
||||
}
|
||||
if (farmId) {
|
||||
return this.genomeService.findRequestsByFarmId(+farmId);
|
||||
}
|
||||
return this.genomeService.findAllRequests();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /genome/request/:cowId
|
||||
* 개체식별번호(KOR...)로 유전체 분석 의뢰 정보 조회
|
||||
* @param cowId - 개체식별번호
|
||||
*/
|
||||
@Get('request/:cowId')
|
||||
findRequestByCowIdentifier(@Param('cowId') cowId: string) {
|
||||
return this.genomeService.findRequestByCowIdentifier(cowId);
|
||||
}
|
||||
|
||||
@Post('request')
|
||||
createRequest(@Body() data: Partial<GenomeRequestModel>) {
|
||||
return this.genomeService.createRequest(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /genome/comparison-averages/:cowId
|
||||
* 개체 기준 전국/지역/농장 카테고리별 평균 EBV 비교 데이터
|
||||
* @param cowId - 개체식별번호 (KOR...)
|
||||
*/
|
||||
@Get('comparison-averages/:cowId')
|
||||
getComparisonAverages(@Param('cowId') cowId: string): Promise<ComparisonAveragesDto> {
|
||||
return this.genomeService.getComparisonAverages(cowId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /genome/trait-comparison-averages/:cowId
|
||||
* 개체 기준 전국/지역/농장 형질별 평균 EBV 비교 데이터
|
||||
* (폴리곤 차트용 - 형질 단위 비교)
|
||||
* @param cowId - 개체식별번호 (KOR...)
|
||||
*/
|
||||
@Get('trait-comparison-averages/:cowId')
|
||||
getTraitComparisonAverages(@Param('cowId') cowId: string) {
|
||||
return this.genomeService.getTraitComparisonAverages(cowId);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /genome/selection-index/:cowId
|
||||
* 선발지수(가중 평균) 계산 + 농가/지역 순위
|
||||
* @param cowId - 개체식별번호 (KOR...)
|
||||
* @param body.traitConditions - 형질별 가중치 조건
|
||||
*/
|
||||
@Post('selection-index/:cowId')
|
||||
getSelectionIndex(
|
||||
@Param('cowId') cowId: string,
|
||||
@Body() body: { traitConditions: { traitNm: string; weight?: number }[] }
|
||||
) {
|
||||
return this.genomeService.getSelectionIndex(cowId, body.traitConditions);
|
||||
}
|
||||
|
||||
|
||||
// Genome Trait Detail endpoints
|
||||
@Get('trait-detail/:requestId')
|
||||
findTraitDetailsByRequestId(@Param('requestId') requestId: string) {
|
||||
return this.genomeService.findTraitDetailsByRequestId(+requestId);
|
||||
}
|
||||
|
||||
@Get('trait-detail/cow/:cowId')
|
||||
findTraitDetailsByCowId(@Param('cowId') cowId: string) {
|
||||
return this.genomeService.findTraitDetailsByCowId(cowId);
|
||||
}
|
||||
|
||||
@Post('trait-detail')
|
||||
createTraitDetail(@Body() data: Partial<GenomeTraitDetailModel>) {
|
||||
return this.genomeService.createTraitDetail(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /genome/check-cow/:cowId
|
||||
* 특정 개체 상세 정보 조회 (디버깅용)
|
||||
*/
|
||||
@Public()
|
||||
@Get('check-cow/:cowId')
|
||||
checkSpecificCow(@Param('cowId') cowId: string) {
|
||||
return this.genomeService.checkSpecificCows([cowId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /genome/yearly-trait-trend/:farmNo
|
||||
* 연도별 유전능력 추이 (형질별/카테고리별)
|
||||
* @param farmNo - 농장 번호
|
||||
* @param category - 카테고리명 (성장/생산/체형/무게/비율)
|
||||
* @param traitName - 형질명 (선택, 없으면 카테고리 전체)
|
||||
*/
|
||||
@Get('yearly-trait-trend/:farmNo')
|
||||
getYearlyTraitTrend(
|
||||
@Param('farmNo') farmNo: string,
|
||||
@Query('category') category: string,
|
||||
@Query('traitName') traitName?: string,
|
||||
) {
|
||||
return this.genomeService.getYearlyTraitTrend(+farmNo, category, traitName);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /genome/:cowId
|
||||
* cowId(개체식별번호)로 유전체 데이터 조회
|
||||
* @Get(':cowId')가 /genome/request 요청을 가로챔
|
||||
* 구체적인 경로들(request)이 위에, 와일드카드 경로(@Get(':cowId'))가 맨 아래
|
||||
*/
|
||||
@Get(':cowId')
|
||||
findByCowId(@Param('cowId') cowId: string) {
|
||||
return this.genomeService.findByCowId(cowId);
|
||||
}
|
||||
}
|
||||
23
backend/src/genome/genome.module.ts
Normal file
23
backend/src/genome/genome.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { GenomeController } from './genome.controller';
|
||||
import { GenomeService } from './genome.service';
|
||||
import { GenomeRequestModel } from './entities/genome-request.entity';
|
||||
import { GenomeTraitDetailModel } from './entities/genome-trait-detail.entity';
|
||||
import { CowModel } from '../cow/entities/cow.entity';
|
||||
import { FarmModel } from '../farm/entities/farm.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
GenomeRequestModel,
|
||||
GenomeTraitDetailModel,
|
||||
CowModel,
|
||||
FarmModel,
|
||||
]),
|
||||
],
|
||||
controllers: [GenomeController],
|
||||
providers: [GenomeService],
|
||||
exports: [GenomeService],
|
||||
})
|
||||
export class GenomeModule {}
|
||||
2043
backend/src/genome/genome.service.ts
Normal file
2043
backend/src/genome/genome.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
58
backend/src/help/dto/create-help.dto.ts
Normal file
58
backend/src/help/dto/create-help.dto.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { IsNotEmpty, IsString, IsOptional, IsInt, MaxLength, IsIn } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 도움말 생성 DTO
|
||||
*
|
||||
* @export
|
||||
* @class CreateHelpDto
|
||||
*/
|
||||
export class CreateHelpDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@IsIn(['SNP', 'GENOME', 'MPT'])
|
||||
@MaxLength(20)
|
||||
helpCtgry: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
targetNm: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
helpTitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
helpShort?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
helpFull?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
helpImageUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
helpVideoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
helpLinkUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
displayOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['Y', 'N'])
|
||||
@MaxLength(1)
|
||||
useYn?: string;
|
||||
}
|
||||
26
backend/src/help/dto/filter-help.dto.ts
Normal file
26
backend/src/help/dto/filter-help.dto.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { IsOptional, IsString, IsIn, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 도움말 필터링 DTO
|
||||
*
|
||||
* @export
|
||||
* @class FilterHelpDto
|
||||
*/
|
||||
export class FilterHelpDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['SNP', 'GENOME', 'MPT'])
|
||||
@MaxLength(20)
|
||||
helpCtgry?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
targetNm?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['Y', 'N'])
|
||||
@MaxLength(1)
|
||||
useYn?: string;
|
||||
}
|
||||
11
backend/src/help/dto/update-help.dto.ts
Normal file
11
backend/src/help/dto/update-help.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateHelpDto } from './create-help.dto';
|
||||
|
||||
/**
|
||||
* 도움말 수정 DTO
|
||||
*
|
||||
* @export
|
||||
* @class UpdateHelpDto
|
||||
* @extends {PartialType(CreateHelpDto)}
|
||||
*/
|
||||
export class UpdateHelpDto extends PartialType(CreateHelpDto) {}
|
||||
108
backend/src/help/entities/help.entity.ts
Normal file
108
backend/src/help/entities/help.entity.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { BaseModel } from "src/common/entities/base.entity";
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||
|
||||
@Entity({ name: "tb_help" })
|
||||
export class HelpModel extends BaseModel {
|
||||
@PrimaryGeneratedColumn({
|
||||
name: "pk_help_no",
|
||||
type: "int",
|
||||
comment: "도움말 번호",
|
||||
})
|
||||
pkHelpNo: number;
|
||||
|
||||
@Column({
|
||||
name: "help_ctgry",
|
||||
type: "varchar",
|
||||
length: 20,
|
||||
nullable: false,
|
||||
comment: "분류 (SNP/GENOME/MPT)",
|
||||
})
|
||||
helpCtgry: string;
|
||||
|
||||
@Column({
|
||||
name: "target_nm",
|
||||
type: "varchar",
|
||||
length: 100,
|
||||
nullable: false,
|
||||
comment: "대상명 (PLAG1, 도체중, 혈당 등)",
|
||||
})
|
||||
targetNm: string;
|
||||
|
||||
@Column({
|
||||
name: "help_title",
|
||||
type: "varchar",
|
||||
length: 200,
|
||||
nullable: true,
|
||||
comment: "제목",
|
||||
})
|
||||
helpTitle: string;
|
||||
|
||||
@Column({
|
||||
name: "help_short",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
comment: "짧은 설명 (툴팁용)",
|
||||
})
|
||||
helpShort: string;
|
||||
|
||||
@Column({
|
||||
name: "help_full",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
comment: "상세 설명 (사이드패널용)",
|
||||
})
|
||||
helpFull: string;
|
||||
|
||||
@Column({
|
||||
name: "help_image_url",
|
||||
type: "varchar",
|
||||
length: 500,
|
||||
nullable: true,
|
||||
comment: "이미지 URL",
|
||||
})
|
||||
helpImageUrl: string;
|
||||
|
||||
@Column({
|
||||
name: "help_video_url",
|
||||
type: "varchar",
|
||||
length: 500,
|
||||
nullable: true,
|
||||
comment: "영상 URL",
|
||||
})
|
||||
helpVideoUrl: string;
|
||||
|
||||
@Column({
|
||||
name: "help_link_url",
|
||||
type: "varchar",
|
||||
length: 500,
|
||||
nullable: true,
|
||||
comment: "참고 링크 URL",
|
||||
})
|
||||
helpLinkUrl: string;
|
||||
|
||||
@Column({
|
||||
name: "display_order",
|
||||
type: "int",
|
||||
nullable: true,
|
||||
comment: "표시 순서",
|
||||
})
|
||||
displayOrder: number;
|
||||
|
||||
@Column({
|
||||
name: "use_yn",
|
||||
type: "char",
|
||||
length: 1,
|
||||
nullable: false,
|
||||
default: "Y",
|
||||
comment: "사용 여부 (Y/N)",
|
||||
})
|
||||
useYn: string;
|
||||
|
||||
// BaseModel에서 상속받는 컬럼들:
|
||||
// - regDt: 등록일시
|
||||
// - updtDt: 수정일시
|
||||
// - regIp: 등록 IP
|
||||
// - updtIp: 수정 IP
|
||||
// - regUserId: 등록자 ID
|
||||
// - updtUserId: 수정자 ID
|
||||
}
|
||||
185
backend/src/help/help.controller.ts
Normal file
185
backend/src/help/help.controller.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, Req } from '@nestjs/common';
|
||||
import { HelpService } from './help.service';
|
||||
import { CreateHelpDto } from './dto/create-help.dto';
|
||||
import { UpdateHelpDto } from './dto/update-help.dto';
|
||||
import { FilterHelpDto } from './dto/filter-help.dto';
|
||||
import { Request } from 'express';
|
||||
|
||||
/**
|
||||
* Help Controller
|
||||
*
|
||||
* @description
|
||||
* 도움말/툴팁 시스템 API 엔드포인트를 제공합니다.
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 도움말 CRUD (생성, 조회, 수정, 삭제)
|
||||
* - 카테고리별 조회 (SNP/GENOME/MPT)
|
||||
* - 대상명별 조회 (PLAG1, 도체중 등)
|
||||
* - 툴팁 데이터 제공
|
||||
*
|
||||
* @export
|
||||
* @class HelpController
|
||||
*/
|
||||
@Controller('help')
|
||||
export class HelpController {
|
||||
constructor(private readonly helpService: HelpService) {}
|
||||
|
||||
/**
|
||||
* POST /help - 도움말 생성 (관리자)
|
||||
*
|
||||
* @description
|
||||
* 새로운 도움말을 생성합니다.
|
||||
*
|
||||
* @example
|
||||
* // POST /help
|
||||
* {
|
||||
* "helpCtgry": "SNP",
|
||||
* "targetNm": "PLAG1",
|
||||
* "helpTitle": "PLAG1 유전자란?",
|
||||
* "helpShort": "체고 및 성장 관련 유전자",
|
||||
* "helpFull": "PLAG1은 소의 체고와 성장에 영향을 미치는 주요 유전자입니다...",
|
||||
* "displayOrder": 1,
|
||||
* "useYn": "Y"
|
||||
* }
|
||||
*
|
||||
* @param {CreateHelpDto} createHelpDto - 생성할 도움말 데이터
|
||||
* @param {Request} req - Express Request 객체
|
||||
* @returns {Promise<HelpModel>}
|
||||
*/
|
||||
@Post()
|
||||
async create(@Body() createHelpDto: CreateHelpDto, @Req() req: Request) {
|
||||
const userId = (req as any).user?.userId || 'system';
|
||||
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
return await this.helpService.create(createHelpDto, userId, ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /help - 전체 도움말 목록 조회
|
||||
*
|
||||
* @description
|
||||
* 전체 도움말 목록을 조회합니다. 필터 조건을 통해 검색 가능합니다.
|
||||
*
|
||||
* @example
|
||||
* // GET /help
|
||||
* // GET /help?helpCtgry=SNP
|
||||
* // GET /help?useYn=Y
|
||||
* // GET /help?targetNm=PLAG1
|
||||
*
|
||||
* @param {FilterHelpDto} filterDto - 필터 조건 (선택)
|
||||
* @returns {Promise<HelpModel[]>}
|
||||
*/
|
||||
@Get()
|
||||
async findAll(@Query() filterDto: FilterHelpDto) {
|
||||
return await this.helpService.findAll(filterDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /help/category/:category - 카테고리별 도움말 조회
|
||||
*
|
||||
* @description
|
||||
* 특정 카테고리(SNP/GENOME/MPT)의 모든 도움말을 조회합니다.
|
||||
*
|
||||
* @example
|
||||
* // GET /help/category/SNP
|
||||
* // GET /help/category/GENOME
|
||||
* // GET /help/category/MPT
|
||||
*
|
||||
* @param {string} category - 카테고리 (SNP/GENOME/MPT)
|
||||
* @returns {Promise<HelpModel[]>}
|
||||
*/
|
||||
@Get('category/:category')
|
||||
async findByCategory(@Param('category') category: string) {
|
||||
return await this.helpService.findByCategory(category);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /help/:category/:targetNm - 특정 대상의 도움말 조회
|
||||
*
|
||||
* @description
|
||||
* 특정 카테고리와 대상명에 해당하는 도움말을 조회합니다.
|
||||
* 툴팁이나 사이드패널에서 사용됩니다.
|
||||
*
|
||||
* @example
|
||||
* // GET /help/SNP/PLAG1
|
||||
* // GET /help/GENOME/도체중
|
||||
* // GET /help/MPT/혈당
|
||||
*
|
||||
* @param {string} category - 카테고리 (SNP/GENOME/MPT)
|
||||
* @param {string} targetNm - 대상명 (PLAG1, 도체중 등)
|
||||
* @returns {Promise<HelpModel>}
|
||||
*/
|
||||
@Get(':category/:targetNm')
|
||||
async findByTarget(
|
||||
@Param('category') category: string,
|
||||
@Param('targetNm') targetNm: string,
|
||||
) {
|
||||
return await this.helpService.findByTarget(category, targetNm);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /help/id/:id - 도움말 단건 조회
|
||||
*
|
||||
* @description
|
||||
* 도움말 번호로 단건을 조회합니다.
|
||||
*
|
||||
* @example
|
||||
* // GET /help/id/1
|
||||
*
|
||||
* @param {number} id - 도움말 번호
|
||||
* @returns {Promise<HelpModel>}
|
||||
*/
|
||||
@Get('id/:id')
|
||||
async findOne(@Param('id') id: number) {
|
||||
return await this.helpService.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /help/:id - 도움말 수정 (관리자)
|
||||
*
|
||||
* @description
|
||||
* 기존 도움말을 수정합니다.
|
||||
*
|
||||
* @example
|
||||
* // PUT /help/1
|
||||
* {
|
||||
* "helpTitle": "수정된 제목",
|
||||
* "helpShort": "수정된 짧은 설명",
|
||||
* "displayOrder": 2
|
||||
* }
|
||||
*
|
||||
* @param {number} id - 도움말 번호
|
||||
* @param {UpdateHelpDto} updateHelpDto - 수정할 데이터
|
||||
* @param {Request} req - Express Request 객체
|
||||
* @returns {Promise<HelpModel>}
|
||||
*/
|
||||
@Put(':id')
|
||||
async update(
|
||||
@Param('id') id: number,
|
||||
@Body() updateHelpDto: UpdateHelpDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const userId = (req as any).user?.userId || 'system';
|
||||
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
return await this.helpService.update(id, updateHelpDto, userId, ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /help/:id - 도움말 삭제 (관리자)
|
||||
*
|
||||
* @description
|
||||
* 도움말을 삭제합니다 (soft delete - useYn = 'N').
|
||||
*
|
||||
* @example
|
||||
* // DELETE /help/1
|
||||
*
|
||||
* @param {number} id - 도움말 번호
|
||||
* @param {Request} req - Express Request 객체
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
@Delete(':id')
|
||||
async remove(@Param('id') id: number, @Req() req: Request) {
|
||||
const userId = (req as any).user?.userId || 'system';
|
||||
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
return await this.helpService.remove(id, userId, ip);
|
||||
}
|
||||
}
|
||||
28
backend/src/help/help.module.ts
Normal file
28
backend/src/help/help.module.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { HelpController } from './help.controller';
|
||||
import { HelpService } from './help.service';
|
||||
import { HelpModel } from './entities/help.entity';
|
||||
|
||||
/**
|
||||
* Help Module
|
||||
*
|
||||
* @description
|
||||
* 도움말/툴팁 시스템 모듈입니다.
|
||||
* SNP, GENOME, MPT 등의 용어에 대한 설명을 제공합니다.
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 도움말 CRUD
|
||||
* - 카테고리별 조회
|
||||
* - 툴팁/사이드패널 데이터 제공
|
||||
*
|
||||
* @export
|
||||
* @class HelpModule
|
||||
*/
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([HelpModel])],
|
||||
controllers: [HelpController],
|
||||
providers: [HelpService],
|
||||
exports: [HelpService],
|
||||
})
|
||||
export class HelpModule {}
|
||||
179
backend/src/help/help.service.ts
Normal file
179
backend/src/help/help.service.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { HelpModel } from './entities/help.entity';
|
||||
import { CreateHelpDto } from './dto/create-help.dto';
|
||||
import { UpdateHelpDto } from './dto/update-help.dto';
|
||||
import { FilterHelpDto } from './dto/filter-help.dto';
|
||||
|
||||
/**
|
||||
* Help Service
|
||||
*
|
||||
* @description
|
||||
* 도움말/툴팁 시스템 서비스입니다.
|
||||
* SNP, GENOME, MPT 등의 용어에 대한 도움말을 제공합니다.
|
||||
*
|
||||
* @export
|
||||
* @class HelpService
|
||||
*/
|
||||
@Injectable()
|
||||
export class HelpService {
|
||||
constructor(
|
||||
@InjectRepository(HelpModel)
|
||||
private readonly helpRepository: Repository<HelpModel>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 도움말 생성
|
||||
*
|
||||
* @param {CreateHelpDto} createHelpDto - 생성할 도움말 데이터
|
||||
* @param {string} userId - 생성자 ID
|
||||
* @param {string} ip - 생성자 IP
|
||||
* @returns {Promise<HelpModel>}
|
||||
*/
|
||||
async create(createHelpDto: CreateHelpDto, userId: string, ip: string): Promise<HelpModel> {
|
||||
const help = this.helpRepository.create({
|
||||
...createHelpDto,
|
||||
regUserId: userId,
|
||||
regIp: ip,
|
||||
useYn: createHelpDto.useYn || 'Y',
|
||||
});
|
||||
|
||||
return await this.helpRepository.save(help);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 도움말 목록 조회
|
||||
*
|
||||
* @param {FilterHelpDto} filterDto - 필터 조건 (선택)
|
||||
* @returns {Promise<HelpModel[]>}
|
||||
*/
|
||||
async findAll(filterDto?: FilterHelpDto): Promise<HelpModel[]> {
|
||||
const queryBuilder = this.helpRepository.createQueryBuilder('help');
|
||||
|
||||
if (filterDto?.helpCtgry) {
|
||||
queryBuilder.andWhere('help.helpCtgry = :helpCtgry', { helpCtgry: filterDto.helpCtgry });
|
||||
}
|
||||
|
||||
if (filterDto?.targetNm) {
|
||||
queryBuilder.andWhere('help.targetNm LIKE :targetNm', { targetNm: `%${filterDto.targetNm}%` });
|
||||
}
|
||||
|
||||
if (filterDto?.useYn) {
|
||||
queryBuilder.andWhere('help.useYn = :useYn', { useYn: filterDto.useYn });
|
||||
}
|
||||
|
||||
return await queryBuilder
|
||||
.orderBy('help.displayOrder', 'ASC')
|
||||
.addOrderBy('help.pkHelpNo', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 도움말 조회
|
||||
*
|
||||
* @param {string} category - 카테고리 (SNP/GENOME/MPT)
|
||||
* @returns {Promise<HelpModel[]>}
|
||||
*/
|
||||
async findByCategory(category: string): Promise<HelpModel[]> {
|
||||
return await this.helpRepository.find({
|
||||
where: {
|
||||
helpCtgry: category,
|
||||
useYn: 'Y',
|
||||
},
|
||||
order: {
|
||||
displayOrder: 'ASC',
|
||||
pkHelpNo: 'DESC',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 대상명의 도움말 조회
|
||||
*
|
||||
* @param {string} category - 카테고리 (SNP/GENOME/MPT)
|
||||
* @param {string} targetNm - 대상명 (예: PLAG1, 도체중 등)
|
||||
* @returns {Promise<HelpModel>}
|
||||
*/
|
||||
async findByTarget(category: string, targetNm: string): Promise<HelpModel> {
|
||||
const help = await this.helpRepository.findOne({
|
||||
where: {
|
||||
helpCtgry: category,
|
||||
targetNm: targetNm,
|
||||
useYn: 'Y',
|
||||
},
|
||||
});
|
||||
|
||||
if (!help) {
|
||||
throw new NotFoundException(`도움말을 찾을 수 없습니다. (카테고리: ${category}, 대상: ${targetNm})`);
|
||||
}
|
||||
|
||||
return help;
|
||||
}
|
||||
|
||||
/**
|
||||
* 도움말 번호로 단건 조회
|
||||
*
|
||||
* @param {number} id - 도움말 번호
|
||||
* @returns {Promise<HelpModel>}
|
||||
*/
|
||||
async findOne(id: number): Promise<HelpModel> {
|
||||
const help = await this.helpRepository.findOne({
|
||||
where: { pkHelpNo: id },
|
||||
});
|
||||
|
||||
if (!help) {
|
||||
throw new NotFoundException(`도움말을 찾을 수 없습니다. (ID: ${id})`);
|
||||
}
|
||||
|
||||
return help;
|
||||
}
|
||||
|
||||
/**
|
||||
* 도움말 수정
|
||||
*
|
||||
* @param {number} id - 도움말 번호
|
||||
* @param {UpdateHelpDto} updateHelpDto - 수정할 데이터
|
||||
* @param {string} userId - 수정자 ID
|
||||
* @param {string} ip - 수정자 IP
|
||||
* @returns {Promise<HelpModel>}
|
||||
*/
|
||||
async update(id: number, updateHelpDto: UpdateHelpDto, userId: string, ip: string): Promise<HelpModel> {
|
||||
const help = await this.findOne(id);
|
||||
|
||||
Object.assign(help, updateHelpDto);
|
||||
help.updtUserId = userId;
|
||||
help.updtIp = ip;
|
||||
|
||||
return await this.helpRepository.save(help);
|
||||
}
|
||||
|
||||
/**
|
||||
* 도움말 삭제 (soft delete - useYn = 'N')
|
||||
*
|
||||
* @param {number} id - 도움말 번호
|
||||
* @param {string} userId - 삭제자 ID
|
||||
* @param {string} ip - 삭제자 IP
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async remove(id: number, userId: string, ip: string): Promise<void> {
|
||||
const help = await this.findOne(id);
|
||||
|
||||
help.useYn = 'N';
|
||||
help.updtUserId = userId;
|
||||
help.updtIp = ip;
|
||||
|
||||
await this.helpRepository.save(help);
|
||||
}
|
||||
|
||||
/**
|
||||
* 도움말 영구 삭제 (hard delete)
|
||||
*
|
||||
* @param {number} id - 도움말 번호
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async hardRemove(id: number): Promise<void> {
|
||||
const help = await this.findOne(id);
|
||||
await this.helpRepository.remove(help);
|
||||
}
|
||||
}
|
||||
51
backend/src/main.ts
Normal file
51
backend/src/main.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NestFactory, Reflector } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||
import { types } from 'pg';
|
||||
|
||||
// PostgreSQL numeric/decimal 타입을 JavaScript number로 자동 변환
|
||||
// 1700 = numeric type OID
|
||||
types.setTypeParser(1700, parseFloat);
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// CORS 추가
|
||||
app.enableCors({
|
||||
origin: ['http://localhost:3000', 'http://192.168.11.249:3000'], // 프론트 서버 둘다 허용
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// ValidationPipe 추가 (Body 파싱과 Dto 유효성 global 설정)
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: false, // nested object를 위해 whitelist 비활성화
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// 전역 필터 적용 - 모든 예외를 잡아서 일관된 형식으로 응답
|
||||
app.useGlobalFilters(new AllExceptionsFilter());
|
||||
|
||||
// 전역 인터셉터 적용
|
||||
app.useGlobalInterceptors(
|
||||
new LoggingInterceptor(), // 요청/응답 로깅
|
||||
new TransformInterceptor(), // 일관된 응답 변환 (success, data, timestamp)
|
||||
//backend\src\common\interceptors\transform.interceptor.ts 구현체
|
||||
);
|
||||
|
||||
// 전역 JWT 인증 가드 적용 (@Public 데코레이터가 있는 엔드포인트는 제외)
|
||||
const reflector = app.get(Reflector);
|
||||
app.useGlobalGuards(new JwtAuthGuard(reflector));
|
||||
|
||||
await app.listen(process.env.PORT ?? 4000); // 로컬 개발환경
|
||||
//await app.listen(process.env.PORT ?? 4000, '0.0.0.0'); // 모든 네트워크 외부 바인딩
|
||||
}
|
||||
bootstrap();
|
||||
107
backend/src/mpt/entities/mpt.entity.ts
Normal file
107
backend/src/mpt/entities/mpt.entity.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { BaseModel } from 'src/common/entities/base.entity';
|
||||
import { FarmModel } from 'src/farm/entities/farm.entity';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* 혈액화학검사 결과 (1개체 N검사)
|
||||
*/
|
||||
@Entity({ name: 'tb_mpt' })
|
||||
export class MptModel extends BaseModel {
|
||||
@PrimaryGeneratedColumn({
|
||||
name: 'pk_mpt_no',
|
||||
type: 'int',
|
||||
comment: 'MPT 번호 (PK)',
|
||||
})
|
||||
pkMptNo: number;
|
||||
|
||||
@Column({
|
||||
name: 'cow_short_no',
|
||||
type: 'varchar',
|
||||
length: 4,
|
||||
nullable: true,
|
||||
comment: '개체 요약번호',
|
||||
})
|
||||
cowShortNo: string;
|
||||
|
||||
@Column({
|
||||
name: 'fk_farm_no',
|
||||
type: 'int',
|
||||
nullable: true,
|
||||
comment: '농장번호 FK',
|
||||
})
|
||||
fkFarmNo: number;
|
||||
|
||||
@Column({ name: 'test_dt', type: 'date', nullable: true, comment: '검사일자' })
|
||||
testDt: Date;
|
||||
|
||||
@Column({ name: 'month_age', type: 'int', nullable: true, comment: '월령' })
|
||||
monthAge: number;
|
||||
|
||||
@Column({ name: 'milk_yield', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '유량' })
|
||||
milkYield: number;
|
||||
|
||||
@Column({ name: 'parity', type: 'int', nullable: true, comment: '산차' })
|
||||
parity: number;
|
||||
|
||||
@Column({ name: 'glucose', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '혈당' })
|
||||
glucose: number;
|
||||
|
||||
@Column({ name: 'cholesterol', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '콜레스테롤' })
|
||||
cholesterol: number;
|
||||
|
||||
@Column({ name: 'nefa', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '유리지방산(NEFA)' })
|
||||
nefa: number;
|
||||
|
||||
@Column({ name: 'bcs', type: 'decimal', precision: 5, scale: 2, nullable: true, comment: 'BCS' })
|
||||
bcs: number;
|
||||
|
||||
@Column({ name: 'total_protein', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '총단백질' })
|
||||
totalProtein: number;
|
||||
|
||||
@Column({ name: 'albumin', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '알부민' })
|
||||
albumin: number;
|
||||
|
||||
@Column({ name: 'globulin', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '총글로불린' })
|
||||
globulin: number;
|
||||
|
||||
@Column({ name: 'ag_ratio', type: 'decimal', precision: 5, scale: 2, nullable: true, comment: 'A/G 비율' })
|
||||
agRatio: number;
|
||||
|
||||
@Column({ name: 'bun', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '요소태질소(BUN)' })
|
||||
bun: number;
|
||||
|
||||
@Column({ name: 'ast', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: 'AST' })
|
||||
ast: number;
|
||||
|
||||
@Column({ name: 'ggt', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: 'GGT' })
|
||||
ggt: number;
|
||||
|
||||
@Column({ name: 'fatty_liver_idx', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '지방간지수' })
|
||||
fattyLiverIdx: number;
|
||||
|
||||
@Column({ name: 'calcium', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '칼슘' })
|
||||
calcium: number;
|
||||
|
||||
@Column({ name: 'phosphorus', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '인' })
|
||||
phosphorus: number;
|
||||
|
||||
@Column({ name: 'ca_p_ratio', type: 'decimal', precision: 5, scale: 2, nullable: true, comment: '칼슘/인 비율' })
|
||||
caPRatio: number;
|
||||
|
||||
@Column({ name: 'magnesium', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '마그네슘' })
|
||||
magnesium: number;
|
||||
|
||||
@Column({ name: 'creatinine', type: 'decimal', precision: 10, scale: 2, nullable: true, comment: '크레아틴' })
|
||||
creatinine: number;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => FarmModel, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'fk_farm_no' })
|
||||
farm: FarmModel;
|
||||
}
|
||||
47
backend/src/mpt/mpt.controller.ts
Normal file
47
backend/src/mpt/mpt.controller.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
|
||||
import { MptService } from './mpt.service';
|
||||
import { MptModel } from './entities/mpt.entity';
|
||||
|
||||
@Controller('mpt')
|
||||
export class MptController {
|
||||
constructor(private readonly mptService: MptService) {}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Query('farmId') farmId?: string,
|
||||
@Query('cowShortNo') cowShortNo?: string,
|
||||
) {
|
||||
if (farmId) {
|
||||
return this.mptService.findByFarmId(+farmId);
|
||||
}
|
||||
if (cowShortNo) {
|
||||
return this.mptService.findByCowShortNo(cowShortNo);
|
||||
}
|
||||
return this.mptService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.mptService.findOne(+id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() data: Partial<MptModel>) {
|
||||
return this.mptService.create(data);
|
||||
}
|
||||
|
||||
@Post('bulk')
|
||||
bulkCreate(@Body() data: Partial<MptModel>[]) {
|
||||
return this.mptService.bulkCreate(data);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() data: Partial<MptModel>) {
|
||||
return this.mptService.update(+id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.mptService.remove(+id);
|
||||
}
|
||||
}
|
||||
13
backend/src/mpt/mpt.module.ts
Normal file
13
backend/src/mpt/mpt.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { MptController } from './mpt.controller';
|
||||
import { MptService } from './mpt.service';
|
||||
import { MptModel } from './entities/mpt.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([MptModel])],
|
||||
controllers: [MptController],
|
||||
providers: [MptService],
|
||||
exports: [MptService],
|
||||
})
|
||||
export class MptModule {}
|
||||
68
backend/src/mpt/mpt.service.ts
Normal file
68
backend/src/mpt/mpt.service.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull } from 'typeorm';
|
||||
import { MptModel } from './entities/mpt.entity';
|
||||
|
||||
@Injectable()
|
||||
export class MptService {
|
||||
constructor(
|
||||
@InjectRepository(MptModel)
|
||||
private readonly mptRepository: Repository<MptModel>,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<MptModel[]> {
|
||||
return this.mptRepository.find({
|
||||
where: { delDt: IsNull() },
|
||||
relations: ['farm'],
|
||||
order: { testDt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findByFarmId(farmNo: number): Promise<MptModel[]> {
|
||||
return this.mptRepository.find({
|
||||
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||
relations: ['farm'],
|
||||
order: { testDt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findByCowShortNo(cowShortNo: string): Promise<MptModel[]> {
|
||||
return this.mptRepository.find({
|
||||
where: { cowShortNo: cowShortNo, delDt: IsNull() },
|
||||
relations: ['farm'],
|
||||
order: { testDt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<MptModel> {
|
||||
const mpt = await this.mptRepository.findOne({
|
||||
where: { pkMptNo: id, delDt: IsNull() },
|
||||
relations: ['farm'],
|
||||
});
|
||||
if (!mpt) {
|
||||
throw new NotFoundException(`MPT #${id} not found`);
|
||||
}
|
||||
return mpt;
|
||||
}
|
||||
|
||||
async create(data: Partial<MptModel>): Promise<MptModel> {
|
||||
const mpt = this.mptRepository.create(data);
|
||||
return this.mptRepository.save(mpt);
|
||||
}
|
||||
|
||||
async bulkCreate(data: Partial<MptModel>[]): Promise<MptModel[]> {
|
||||
const mpts = this.mptRepository.create(data);
|
||||
return this.mptRepository.save(mpts);
|
||||
}
|
||||
|
||||
async update(id: number, data: Partial<MptModel>): Promise<MptModel> {
|
||||
await this.findOne(id);
|
||||
await this.mptRepository.update(id, data);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
const mpt = await this.findOne(id);
|
||||
await this.mptRepository.softRemove(mpt);
|
||||
}
|
||||
}
|
||||
32
backend/src/redis/redis.module.ts
Normal file
32
backend/src/redis/redis.module.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { RedisModule as NestRedisModule } from '@nestjs-modules/ioredis';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
|
||||
/**
|
||||
* RedisModule
|
||||
*
|
||||
* @description
|
||||
* Redis 연결 설정을 담당하는 전역 모듈입니다.
|
||||
* 캐시, 세션 관리, 인증번호 저장 등에 사용됩니다.
|
||||
*
|
||||
* @Global 데코레이터로 전역에서 사용 가능하므로,
|
||||
* 필요한 서비스에서 @InjectRedis()로 바로 주입할 수 있습니다.
|
||||
*
|
||||
* @export
|
||||
* @class RedisModule
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
NestRedisModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
type: 'single',
|
||||
url: configService.get('REDIS_URL'),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
exports: [NestRedisModule],
|
||||
})
|
||||
export class RedisModule {}
|
||||
23
backend/src/shared/email/email.module.ts
Normal file
23
backend/src/shared/email/email.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EmailService } from './email.service';
|
||||
|
||||
/**
|
||||
* EmailModule
|
||||
*
|
||||
* @description
|
||||
* 이메일 발송 기능을 제공하는 모듈입니다.
|
||||
* nodemailer를 사용하여 SMTP 서버를 통해 이메일을 전송합니다.
|
||||
*
|
||||
* 사용 예:
|
||||
* - 인증번호 발송
|
||||
* - 비밀번호 재설정 안내
|
||||
* - 시스템 알림
|
||||
*
|
||||
* @export
|
||||
* @class EmailModule
|
||||
*/
|
||||
@Module({
|
||||
providers: [EmailService],
|
||||
exports: [EmailService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
55
backend/src/shared/email/email.service.ts
Normal file
55
backend/src/shared/email/email.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
|
||||
/**
|
||||
* 이메일 발송 서비스
|
||||
*
|
||||
* @export
|
||||
* @class EmailService
|
||||
*/
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
private transporter; // nodemailer 전송 객체
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
// SMTP 서버 설정 (AWS SES, Gmail 등)
|
||||
this.transporter = nodemailer.createTransport({ // .env 파일 EMAIL CONFIGURATION
|
||||
host: this.configService.get('SMTP_HOST'),
|
||||
port: parseInt(this.configService.get('SMTP_PORT')),
|
||||
secure: this.configService.get('SMTP_PORT') === '465',
|
||||
auth: {
|
||||
user: this.configService.get('SMTP_USER'),
|
||||
pass: this.configService.get('SMTP_PASS'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증번호 이메일 발송
|
||||
*
|
||||
* @async
|
||||
* @param {string} email - 수신자 이메일
|
||||
* @param {string} code - 6자리 인증번호
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async sendVerificationCode(email: string, code: string): Promise<void> {
|
||||
await this.transporter.sendMail({
|
||||
from: this.configService.get('FROM_EMAIL'),
|
||||
to: email,
|
||||
subject: '[한우 유전능력 시스템] 인증번호 안내',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #333;">인증번호 안내</h2>
|
||||
<p>아래 인증번호를 입력해주세요.</p>
|
||||
<div style="background-color: #f5f5f5; padding: 20px; text-align: center; margin: 20px 0;">
|
||||
<h1 style="color: #4CAF50; font-size: 32px; margin: 0;">${code}</h1>
|
||||
</div>
|
||||
<p style="color: #666;">인증번호는 3분간 유효합니다.</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="color: #999; font-size: 12px;">본 메일은 발신 전용입니다.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
}
|
||||
}
|
||||
8
backend/src/shared/filter/filter-engine.module.ts
Normal file
8
backend/src/shared/filter/filter-engine.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FilterEngineService } from './filter-engine.service';
|
||||
|
||||
@Module({
|
||||
providers: [FilterEngineService],
|
||||
exports: [FilterEngineService],
|
||||
})
|
||||
export class FilterEngineModule {}
|
||||
319
backend/src/shared/filter/filter-engine.service.spec.ts
Normal file
319
backend/src/shared/filter/filter-engine.service.spec.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { FilterEngineService } from './filter-engine.service';
|
||||
import { SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
describe('FilterEngineService', () => { // describe: 테스트 그룹 (테스트할 클래스/모듈)
|
||||
let service: FilterEngineService;
|
||||
let mockQueryBuilder: any;
|
||||
|
||||
beforeEach(async () => { // beforeEach: 각 테스트 실행 전에 실행
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [FilterEngineService], // 테스트할 서비스 주입
|
||||
}).compile();
|
||||
|
||||
service = module.get<FilterEngineService>(FilterEngineService);
|
||||
|
||||
// Mock QueryBuilder
|
||||
mockQueryBuilder = {
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getCount: jest.fn().mockResolvedValue(100),
|
||||
getMany: jest.fn().mockResolvedValue([
|
||||
{ id: 1, name: 'Test1' },
|
||||
{ id: 2, name: 'Test2' },
|
||||
]),
|
||||
};
|
||||
});
|
||||
|
||||
it('should be defined', () => { // it: 개별 테스트 케이스
|
||||
expect(service).toBeDefined(); // expect: 검증
|
||||
});
|
||||
|
||||
describe('applyFilters', () => { // 특정 메서드에 대한 하위 그룹
|
||||
it('필터가 없으면 queryBuilder를 그대로 반환해야 함', () => {
|
||||
const result = service.applyFilters(mockQueryBuilder, []);
|
||||
expect(result).toBe(mockQueryBuilder);
|
||||
expect(mockQueryBuilder.andWhere).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('eq 연산자를 올바르게 적용해야 함', () => {
|
||||
service.applyFilters(mockQueryBuilder, [
|
||||
{ field: 'status', operator: 'eq', value: 'active' },
|
||||
]);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'status = :param_0',
|
||||
{ param_0: 'active' },
|
||||
);
|
||||
});
|
||||
|
||||
it('ne 연산자를 올바르게 적용해야 함', () => {
|
||||
service.applyFilters(mockQueryBuilder, [
|
||||
{ field: 'status', operator: 'ne', value: 'deleted' },
|
||||
]);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'status != :param_0',
|
||||
{ param_0: 'deleted' },
|
||||
);
|
||||
});
|
||||
|
||||
it('gt 연산자를 올바르게 적용해야 함', () => {
|
||||
service.applyFilters(mockQueryBuilder, [
|
||||
{ field: 'age', operator: 'gt', value: 18 },
|
||||
]);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'age > :param_0',
|
||||
{ param_0: 18 },
|
||||
);
|
||||
});
|
||||
|
||||
it('gte 연산자를 올바르게 적용해야 함', () => {
|
||||
service.applyFilters(mockQueryBuilder, [
|
||||
{ field: 'age', operator: 'gte', value: 18 },
|
||||
]);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'age >= :param_0',
|
||||
{ param_0: 18 },
|
||||
);
|
||||
});
|
||||
|
||||
it('lt 연산자를 올바르게 적용해야 함', () => {
|
||||
service.applyFilters(mockQueryBuilder, [
|
||||
{ field: 'age', operator: 'lt', value: 65 },
|
||||
]);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'age < :param_0',
|
||||
{ param_0: 65 },
|
||||
);
|
||||
});
|
||||
|
||||
it('lte 연산자를 올바르게 적용해야 함', () => {
|
||||
service.applyFilters(mockQueryBuilder, [
|
||||
{ field: 'age', operator: 'lte', value: 65 },
|
||||
]);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'age <= :param_0',
|
||||
{ param_0: 65 },
|
||||
);
|
||||
});
|
||||
|
||||
it('like 연산자를 올바르게 적용해야 함 (와일드카드 자동 추가)', () => {
|
||||
service.applyFilters(mockQueryBuilder, [
|
||||
{ field: 'name', operator: 'like', value: 'John' },
|
||||
]);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'name LIKE :param_0',
|
||||
{ param_0: '%John%' },
|
||||
);
|
||||
});
|
||||
|
||||
it('in 연산자를 올바르게 적용해야 함', () => {
|
||||
service.applyFilters(mockQueryBuilder, [
|
||||
{ field: 'status', operator: 'in', value: ['active', 'pending'] },
|
||||
]);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'status IN (:...param_0)',
|
||||
{ param_0: ['active', 'pending'] },
|
||||
);
|
||||
});
|
||||
|
||||
it('between 연산자를 올바르게 적용해야 함', () => {
|
||||
service.applyFilters(mockQueryBuilder, [
|
||||
{ field: 'age', operator: 'between', value: [18, 65] },
|
||||
]);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'age BETWEEN :param_0_min AND :param_0_max',
|
||||
{ param_0_min: 18, param_0_max: 65 },
|
||||
);
|
||||
});
|
||||
|
||||
it('다중 필터를 올바르게 적용해야 함', () => {
|
||||
service.applyFilters(mockQueryBuilder, [
|
||||
{ field: 'status', operator: 'eq', value: 'active' },
|
||||
{ field: 'age', operator: 'gte', value: 18 },
|
||||
{ field: 'name', operator: 'like', value: 'John' },
|
||||
]);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('between 연산자에서 배열 길이가 2가 아니면 무시해야 함', () => {
|
||||
service.applyFilters(mockQueryBuilder, [
|
||||
{ field: 'age', operator: 'between', value: [18] }, // 길이 1
|
||||
]);
|
||||
|
||||
// between은 호출되지 않아야 함
|
||||
expect(mockQueryBuilder.andWhere).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applySortOptions', () => {
|
||||
it('정렬 옵션이 없으면 queryBuilder를 그대로 반환해야 함', () => {
|
||||
const result = service.applySortOptions(mockQueryBuilder, []);
|
||||
expect(result).toBe(mockQueryBuilder);
|
||||
expect(mockQueryBuilder.orderBy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('단일 정렬 옵션을 올바르게 적용해야 함', () => {
|
||||
service.applySortOptions(mockQueryBuilder, [
|
||||
{ field: 'createdAt', order: 'DESC' },
|
||||
]);
|
||||
|
||||
expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('createdAt', 'DESC');
|
||||
});
|
||||
|
||||
it('다중 정렬 옵션을 올바르게 적용해야 함 (첫 번째는 orderBy, 나머지는 addOrderBy)', () => {
|
||||
service.applySortOptions(mockQueryBuilder, [
|
||||
{ field: 'status', order: 'ASC' },
|
||||
{ field: 'createdAt', order: 'DESC' },
|
||||
{ field: 'name', order: 'ASC' },
|
||||
]);
|
||||
|
||||
expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('status', 'ASC');
|
||||
expect(mockQueryBuilder.addOrderBy).toHaveBeenCalledWith('createdAt', 'DESC');
|
||||
expect(mockQueryBuilder.addOrderBy).toHaveBeenCalledWith('name', 'ASC');
|
||||
expect(mockQueryBuilder.addOrderBy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPagination', () => {
|
||||
it('페이지네이션을 올바르게 적용해야 함 (1페이지)', () => {
|
||||
service.applyPagination(mockQueryBuilder, 1, 10);
|
||||
|
||||
expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0);
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('페이지네이션을 올바르게 적용해야 함 (2페이지)', () => {
|
||||
service.applyPagination(mockQueryBuilder, 2, 10);
|
||||
|
||||
expect(mockQueryBuilder.skip).toHaveBeenCalledWith(10);
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('페이지네이션을 올바르게 적용해야 함 (3페이지, limit 20)', () => {
|
||||
service.applyPagination(mockQueryBuilder, 3, 20);
|
||||
|
||||
expect(mockQueryBuilder.skip).toHaveBeenCalledWith(40);
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeFilteredQuery', () => {
|
||||
it('필터, 정렬, 페이지네이션을 모두 적용하고 결과를 반환해야 함', async () => {
|
||||
const options = {
|
||||
filters: [
|
||||
{ field: 'status', operator: 'eq' as const, value: 'active' },
|
||||
],
|
||||
sorts: [
|
||||
{ field: 'createdAt', order: 'DESC' as const },
|
||||
],
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await service.executeFilteredQuery(mockQueryBuilder, options);
|
||||
|
||||
// 필터 적용 확인
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalled();
|
||||
|
||||
// 정렬 적용 확인
|
||||
expect(mockQueryBuilder.orderBy).toHaveBeenCalled();
|
||||
|
||||
// 개수 조회 확인
|
||||
expect(mockQueryBuilder.getCount).toHaveBeenCalled();
|
||||
|
||||
// 페이지네이션 적용 확인
|
||||
expect(mockQueryBuilder.skip).toHaveBeenCalled();
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalled();
|
||||
|
||||
// 데이터 조회 확인
|
||||
expect(mockQueryBuilder.getMany).toHaveBeenCalled();
|
||||
|
||||
// 결과 구조 확인
|
||||
expect(result).toEqual({
|
||||
data: [
|
||||
{ id: 1, name: 'Test1' },
|
||||
{ id: 2, name: 'Test2' },
|
||||
],
|
||||
total: 100,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('페이지네이션 없이 실행할 수 있어야 함', async () => {
|
||||
const options = {
|
||||
filters: [
|
||||
{ field: 'status', operator: 'eq' as const, value: 'active' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = await service.executeFilteredQuery(mockQueryBuilder, options);
|
||||
|
||||
// 페이지네이션은 적용되지 않아야 함
|
||||
expect(mockQueryBuilder.skip).not.toHaveBeenCalled();
|
||||
expect(mockQueryBuilder.take).not.toHaveBeenCalled();
|
||||
|
||||
// 결과에 페이지 정보가 없어야 함
|
||||
expect(result).toEqual({
|
||||
data: [
|
||||
{ id: 1, name: 'Test1' },
|
||||
{ id: 2, name: 'Test2' },
|
||||
],
|
||||
total: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('필터와 정렬 없이 페이지네이션만 적용할 수 있어야 함', async () => {
|
||||
const options = {
|
||||
pagination: {
|
||||
page: 2,
|
||||
limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await service.executeFilteredQuery(mockQueryBuilder, options);
|
||||
|
||||
// 필터와 정렬은 적용되지 않아야 함
|
||||
expect(mockQueryBuilder.andWhere).not.toHaveBeenCalled();
|
||||
expect(mockQueryBuilder.orderBy).not.toHaveBeenCalled();
|
||||
|
||||
// 페이지네이션은 적용되어야 함
|
||||
expect(mockQueryBuilder.skip).toHaveBeenCalledWith(20);
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(20);
|
||||
|
||||
// totalPages 계산 확인
|
||||
expect(result.totalPages).toBe(5); // 100 / 20 = 5
|
||||
});
|
||||
|
||||
it('totalPages를 올바르게 계산해야 함 (나누어떨어지지 않는 경우)', async () => {
|
||||
mockQueryBuilder.getCount.mockResolvedValue(105);
|
||||
|
||||
const options = {
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await service.executeFilteredQuery(mockQueryBuilder, options);
|
||||
|
||||
expect(result.totalPages).toBe(11); // Math.ceil(105 / 10) = 11
|
||||
});
|
||||
});
|
||||
});
|
||||
187
backend/src/shared/filter/filter-engine.service.ts
Normal file
187
backend/src/shared/filter/filter-engine.service.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SelectQueryBuilder } from 'typeorm';
|
||||
import {
|
||||
FilterCondition,
|
||||
FilterEngineOptions,
|
||||
FilterEngineResult,
|
||||
SortOption,
|
||||
} from './interfaces/filter.interface';
|
||||
|
||||
/**
|
||||
* 동적 필터링, 정렬, 페이지네이션을 제공하는 공통 엔진
|
||||
*
|
||||
* @export
|
||||
* @class FilterEngineService
|
||||
*/
|
||||
@Injectable()
|
||||
export class FilterEngineService {
|
||||
/**
|
||||
* QueryBuilder에 필터 조건 적용
|
||||
*
|
||||
* @template T
|
||||
* @param {SelectQueryBuilder<T>} queryBuilder
|
||||
* @param {FilterCondition[]} filters
|
||||
* @returns {SelectQueryBuilder<T>}
|
||||
*/
|
||||
applyFilters<T>(
|
||||
queryBuilder: SelectQueryBuilder<T>,
|
||||
filters: FilterCondition[],
|
||||
): SelectQueryBuilder<T> {
|
||||
if (!filters || filters.length === 0) {
|
||||
return queryBuilder; // 필터 없으면 그대로 반환
|
||||
}
|
||||
|
||||
filters.forEach((filter, index) => {
|
||||
const { field, operator, value } = filter;
|
||||
const paramName = `param_${index}`;
|
||||
|
||||
switch (operator) { // 파라미터 바인딩
|
||||
case 'eq':
|
||||
queryBuilder.andWhere(`${field} = :${paramName}`, { [paramName]: value });
|
||||
break;
|
||||
|
||||
case 'ne':
|
||||
queryBuilder.andWhere(`${field} != :${paramName}`, { [paramName]: value });
|
||||
break;
|
||||
|
||||
case 'gt':
|
||||
queryBuilder.andWhere(`${field} > :${paramName}`, { [paramName]: value });
|
||||
break;
|
||||
|
||||
case 'gte':
|
||||
queryBuilder.andWhere(`${field} >= :${paramName}`, { [paramName]: value });
|
||||
break;
|
||||
|
||||
case 'lt':
|
||||
queryBuilder.andWhere(`${field} < :${paramName}`, { [paramName]: value });
|
||||
break;
|
||||
|
||||
case 'lte':
|
||||
queryBuilder.andWhere(`${field} <= :${paramName}`, { [paramName]: value });
|
||||
break;
|
||||
|
||||
case 'like':
|
||||
queryBuilder.andWhere(`${field} LIKE :${paramName}`, {
|
||||
[paramName]: `%${value}%`,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'in':
|
||||
queryBuilder.andWhere(`${field} IN (:...${paramName})`, {
|
||||
[paramName]: value,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'between':
|
||||
if (Array.isArray(value) && value.length === 2) {
|
||||
queryBuilder.andWhere(`${field} BETWEEN :${paramName}_min AND :${paramName}_max`, {
|
||||
[`${paramName}_min`]: value[0],
|
||||
[`${paramName}_max`]: value[1],
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return queryBuilder;
|
||||
}
|
||||
|
||||
/**
|
||||
* QueryBuilder에 정렬 옵션 적용
|
||||
*
|
||||
* @template T
|
||||
* @param {SelectQueryBuilder<T>} queryBuilder
|
||||
* @param {SortOption[]} sorts
|
||||
* @returns {SelectQueryBuilder<T>}
|
||||
*/
|
||||
applySortOptions<T>(
|
||||
queryBuilder: SelectQueryBuilder<T>,
|
||||
sorts: SortOption[],
|
||||
): SelectQueryBuilder<T> {
|
||||
if (!sorts || sorts.length === 0) {
|
||||
return queryBuilder;
|
||||
}
|
||||
|
||||
sorts.forEach((sort, index) => {
|
||||
if (index === 0) {
|
||||
queryBuilder.orderBy(sort.field, sort.order);
|
||||
} else {
|
||||
queryBuilder.addOrderBy(sort.field, sort.order);
|
||||
}
|
||||
});
|
||||
|
||||
return queryBuilder;
|
||||
}
|
||||
|
||||
/**
|
||||
* QueryBuilder에 페이지네이션 적용
|
||||
*
|
||||
* @template T
|
||||
* @param {SelectQueryBuilder<T>} queryBuilder
|
||||
* @param {number} page
|
||||
* @param {number} limit
|
||||
* @returns {SelectQueryBuilder<T>}
|
||||
*/
|
||||
applyPagination<T>(
|
||||
queryBuilder: SelectQueryBuilder<T>,
|
||||
page: number,
|
||||
limit: number,
|
||||
): SelectQueryBuilder<T> {
|
||||
const skip = (page - 1) * limit;
|
||||
return queryBuilder.skip(skip).take(limit);
|
||||
// SQL: LIMIT 5 OFFSET 10
|
||||
// 10개 건너뛰고, 5개 가져오기 (11~15번째)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터링된 쿼리 실행 및 결과 반환 (전체실행 메인 함수)
|
||||
*
|
||||
* @async
|
||||
* @template T
|
||||
* @param {SelectQueryBuilder<T>} queryBuilder
|
||||
* @param {FilterEngineOptions} options
|
||||
* @returns {Promise<FilterEngineResult<T>>}
|
||||
*/
|
||||
async executeFilteredQuery<T>(
|
||||
queryBuilder: SelectQueryBuilder<T>,
|
||||
options: FilterEngineOptions,
|
||||
): Promise<FilterEngineResult<T>> {
|
||||
// 1. 필터 적용
|
||||
if (options.filters) {
|
||||
this.applyFilters(queryBuilder, options.filters);
|
||||
}
|
||||
|
||||
// 2. 정렬 적용
|
||||
if (options.sorts) {
|
||||
this.applySortOptions(queryBuilder, options.sorts);
|
||||
}
|
||||
|
||||
// 3. 전체 개수 조회 (페이지네이션 전)
|
||||
const total = await queryBuilder.getCount();
|
||||
|
||||
// 4. 페이지네이션 적용
|
||||
if (options.pagination) {
|
||||
const { page, limit } = options.pagination;
|
||||
this.applyPagination(queryBuilder, page, limit);
|
||||
}
|
||||
|
||||
// 5. 데이터 조회
|
||||
const data = await queryBuilder.getMany();
|
||||
|
||||
// 6. 결과 구성
|
||||
const result: FilterEngineResult<T> = {
|
||||
data,
|
||||
total,
|
||||
};
|
||||
|
||||
if (options.pagination) {
|
||||
const { page, limit } = options.pagination;
|
||||
result.page = page;
|
||||
result.limit = limit;
|
||||
result.totalPages = Math.ceil(total / limit);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
91
backend/src/shared/filter/interfaces/filter.interface.ts
Normal file
91
backend/src/shared/filter/interfaces/filter.interface.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**코드 작성 전 TypeScript의 타입 정의 */
|
||||
|
||||
/**
|
||||
* 필터 연산자 타입
|
||||
*/
|
||||
export type FilterOperator =
|
||||
| 'eq' // 같음
|
||||
| 'ne' // 같지 않음
|
||||
| 'gt' // 초과
|
||||
| 'gte' // 이상
|
||||
| 'lt' // 미만
|
||||
| 'lte' // 이하
|
||||
| 'like' // 포함 (문자열)
|
||||
| 'in' // 배열 내 포함
|
||||
| 'between'; // 범위
|
||||
|
||||
/**
|
||||
* 필터 조건
|
||||
*/
|
||||
export interface FilterCondition {
|
||||
/** 필터링할 컬럼명 */
|
||||
field: string;
|
||||
|
||||
/** 연산자 (eq,like)*/
|
||||
operator: FilterOperator;
|
||||
|
||||
/** 비교 값 (between인 경우 [min, max] 배열) */
|
||||
value: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정렬 방향
|
||||
*/
|
||||
export type SortOrder = 'ASC' | 'DESC';
|
||||
|
||||
/**
|
||||
* 정렬 옵션
|
||||
*/
|
||||
export interface SortOption {
|
||||
/** 정렬할 컬럼명 */
|
||||
field: string;
|
||||
|
||||
/** 정렬 방향 */
|
||||
order: SortOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지네이션 옵션
|
||||
*/
|
||||
export interface PaginationOption {
|
||||
/** 페이지 번호 (1부터 시작) */
|
||||
page: number;
|
||||
|
||||
/** 페이지당 아이템 수 */
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* FilterEngine 옵션
|
||||
* FilterEngine에 전달할 모든 옵션을 하나로 묶음
|
||||
*/
|
||||
export interface FilterEngineOptions {
|
||||
/** 필터 조건 배열 */
|
||||
filters?: FilterCondition[];
|
||||
|
||||
/** 정렬 옵션 배열 */
|
||||
sorts?: SortOption[];
|
||||
|
||||
/** 페이지네이션 옵션 */
|
||||
pagination?: PaginationOption;
|
||||
}
|
||||
|
||||
/**
|
||||
* FilterEngine 실행 결과
|
||||
*/
|
||||
export interface FilterEngineResult<T> {
|
||||
/** 조회된 데이터 */
|
||||
data: T[];
|
||||
|
||||
/** 전체 데이터 개수 (페이지네이션 전) */
|
||||
total: number;
|
||||
|
||||
/** 현재 페이지 */
|
||||
page?: number;
|
||||
|
||||
/** 페이지당 아이템 수 */
|
||||
limit?: number;
|
||||
|
||||
/** 전체 페이지 수 */
|
||||
totalPages?: number;
|
||||
}
|
||||
33
backend/src/shared/shared.module.ts
Normal file
33
backend/src/shared/shared.module.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EmailModule } from './email/email.module';
|
||||
import { VerificationModule } from './verification/verification.module';
|
||||
import { FilterEngineModule } from './filter/filter-engine.module';
|
||||
|
||||
/**
|
||||
* SharedModule
|
||||
*
|
||||
* @description
|
||||
* 범용적이고 재사용 가능한 기능들을 담는 통합 모듈입니다.
|
||||
* 특정 도메인에 종속되지 않으며, 여러 피처 모듈에서 광범위하게 사용됩니다.
|
||||
*
|
||||
* 포함된 모듈:
|
||||
* - EmailModule: 이메일 발송 서비스
|
||||
* - VerificationModule: 인증번호 생성 및 검증 서비스
|
||||
* - FilterEngineModule: 동적 필터링, 정렬, 페이지네이션 엔진
|
||||
*
|
||||
* @export
|
||||
* @class SharedModule
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
EmailModule,
|
||||
VerificationModule,
|
||||
FilterEngineModule,
|
||||
],
|
||||
exports: [
|
||||
EmailModule,
|
||||
VerificationModule,
|
||||
FilterEngineModule,
|
||||
],
|
||||
})
|
||||
export class SharedModule {}
|
||||
25
backend/src/shared/verification/verification.module.ts
Normal file
25
backend/src/shared/verification/verification.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { VerificationService } from './verification.service';
|
||||
|
||||
/**
|
||||
* VerificationModule
|
||||
*
|
||||
* @description
|
||||
* 인증번호 생성 및 검증 기능을 제공하는 모듈입니다.
|
||||
* Redis를 사용하여 인증번호를 임시 저장하고 검증합니다.
|
||||
*
|
||||
* 사용 예:
|
||||
* - 아이디 찾기 인증번호 발송
|
||||
* - 비밀번호 재설정 인증번호 발송
|
||||
* - 회원가입 이메일 인증
|
||||
*
|
||||
* RedisModule이 @Global로 설정되어 있어 자동으로 주입됩니다.
|
||||
*
|
||||
* @export
|
||||
* @class VerificationModule
|
||||
*/
|
||||
@Module({
|
||||
providers: [VerificationService],
|
||||
exports: [VerificationService],
|
||||
})
|
||||
export class VerificationModule {}
|
||||
97
backend/src/shared/verification/verification.service.ts
Normal file
97
backend/src/shared/verification/verification.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRedis } from '@nestjs-modules/ioredis';
|
||||
import Redis from 'ioredis';
|
||||
import * as crypto from 'crypto';
|
||||
import { VERIFICATION_CONFIG } from 'src/common/config/VerificationConfig';
|
||||
|
||||
/**
|
||||
* 인증번호 생성 및 검증 서비스 (Redis 기반)
|
||||
*
|
||||
* @export
|
||||
* @class VerificationService
|
||||
*/
|
||||
@Injectable()
|
||||
export class VerificationService {
|
||||
constructor(@InjectRedis() private readonly redis: Redis) {}
|
||||
|
||||
/**
|
||||
* 6자리 인증번호 생성
|
||||
*
|
||||
* @returns {string} 6자리 숫자
|
||||
*/
|
||||
generateCode(): string {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증번호 저장 (Redis 3분 후 자동 삭제)
|
||||
*
|
||||
* @async
|
||||
* @param {string} key - Redis 키 (예: find-id:test@example.com)
|
||||
* @param {string} code - 인증번호
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async saveCode(key: string, code: string): Promise<void> {
|
||||
await this.redis.set(key, code, 'EX', VERIFICATION_CONFIG.CODE_EXPIRY_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증번호 검증
|
||||
*
|
||||
* @async
|
||||
* @param {string} key - Redis 키
|
||||
* @param {string} code - 사용자가 입력한 인증번호
|
||||
* @returns {Promise<boolean>} 검증 성공 여부
|
||||
*/
|
||||
async verifyCode(key: string, code: string): Promise<boolean> {
|
||||
const savedCode = await this.redis.get(key);
|
||||
console.log(`[DEBUG VerificationService] Key: ${key}, Input code: ${code}, Saved code: ${savedCode}`);
|
||||
|
||||
if (!savedCode) {
|
||||
console.log(`[DEBUG VerificationService] No saved code found for key: ${key}`);
|
||||
return false; // 인증번호 없음 (만료 또는 미발급)
|
||||
}
|
||||
|
||||
if (savedCode !== code) {
|
||||
console.log(`[DEBUG VerificationService] Code mismatch - Saved: ${savedCode}, Input: ${code}`);
|
||||
return false; // 인증번호 불일치
|
||||
}
|
||||
|
||||
// 검증 성공 시 Redis에서 삭제 (1회용)
|
||||
await this.redis.del(key);
|
||||
console.log(`[DEBUG VerificationService] Code verified successfully!`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 재설정 토큰 생성 및 저장
|
||||
*
|
||||
* @async
|
||||
* @param {string} userId - 사용자 ID
|
||||
* @returns {Promise<string>} 재설정 토큰
|
||||
*/
|
||||
async generateResetToken(userId: string): Promise<string> {
|
||||
const token = crypto.randomBytes(VERIFICATION_CONFIG.TOKEN_BYTES_LENGTH).toString('hex');
|
||||
await this.redis.set(`reset:${token}`, userId, 'EX', VERIFICATION_CONFIG.RESET_TOKEN_EXPIRY_SECONDS);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 재설정 토큰 검증
|
||||
*
|
||||
* @async
|
||||
* @param {string} token - 재설정 토큰
|
||||
* @returns {Promise<string | null>} 사용자 ID 또는 null
|
||||
*/
|
||||
async verifyResetToken(token: string): Promise<string | null> {
|
||||
const userId = await this.redis.get(`reset:${token}`);
|
||||
|
||||
if (!userId) {
|
||||
return null; // 토큰 없음 (만료 또는 미발급)
|
||||
}
|
||||
|
||||
// 검증 성공 시 토큰 삭제 (1회용)
|
||||
await this.redis.del(`reset:${token}`);
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
72
backend/src/user/entities/user.entity.ts
Normal file
72
backend/src/user/entities/user.entity.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { BaseModel } from 'src/common/entities/base.entity';
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 사용자/관리자 정보 (tb_user)
|
||||
*/
|
||||
@Entity({ name: 'tb_user' })
|
||||
export class UserModel extends BaseModel {
|
||||
@PrimaryGeneratedColumn({
|
||||
name: 'pk_user_no',
|
||||
type: 'int',
|
||||
comment: '내부 PK (자동증가)',
|
||||
})
|
||||
pkUserNo: number;
|
||||
|
||||
@Column({
|
||||
name: 'user_id',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: false,
|
||||
unique: true,
|
||||
comment: '로그인 ID',
|
||||
})
|
||||
userId: string;
|
||||
|
||||
@Column({
|
||||
name: 'user_pw',
|
||||
type: 'varchar',
|
||||
length: 200,
|
||||
nullable: false,
|
||||
comment: '비밀번호 (암호화)',
|
||||
})
|
||||
userPw: string;
|
||||
|
||||
@Column({
|
||||
name: 'user_name',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: false,
|
||||
comment: '이름',
|
||||
})
|
||||
userName: string;
|
||||
|
||||
@Column({
|
||||
name: 'user_phone',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
nullable: true,
|
||||
comment: '핸드폰번호',
|
||||
})
|
||||
userPhone: string;
|
||||
|
||||
@Column({
|
||||
name: 'user_email',
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
unique: true,
|
||||
comment: '이메일 (인증용)',
|
||||
})
|
||||
userEmail: string;
|
||||
|
||||
@Column({
|
||||
name: 'user_role',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
nullable: false,
|
||||
default: 'USER',
|
||||
comment: '권한 (USER: 일반, ADMIN: 관리자)',
|
||||
})
|
||||
userRole: 'USER' | 'ADMIN';
|
||||
}
|
||||
20
backend/src/user/user.controller.spec.ts
Normal file
20
backend/src/user/user.controller.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { UserController } from './user.controller';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
describe('UserController', () => {
|
||||
let controller: UserController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<UserController>(UserController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
23
backend/src/user/user.controller.ts
Normal file
23
backend/src/user/user.controller.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Controller } from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
/**
|
||||
* 사용자 정보 관리 컨트롤러
|
||||
*
|
||||
* @description
|
||||
* 사용자 API
|
||||
* 인증 관련 API는 AuthController로 이동
|
||||
*
|
||||
* @export
|
||||
* @class UserController
|
||||
* @typedef {UserController}
|
||||
*/
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(private readonly usersService: UserService) {}
|
||||
|
||||
// TODO: 나중에 프로필 관련 엔드포인트 추가
|
||||
// - GET /users/profile - 내 프로필 조회
|
||||
// - PATCH /users/profile - 프로필 수정
|
||||
// - GET /users/:id - 특정 사용자 조회 (관리자용)
|
||||
}
|
||||
19
backend/src/user/user.module.ts
Normal file
19
backend/src/user/user.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
import { UserController } from './user.controller';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserModel } from './entities/user.entity';
|
||||
|
||||
/**
|
||||
* 사용자 모듈
|
||||
* 프로필 조회/수정 등 사용자 정보 관련 기능 제공
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([UserModel]),
|
||||
],
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
18
backend/src/user/user.service.spec.ts
Normal file
18
backend/src/user/user.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
describe('UserService', () => {
|
||||
let service: UserService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [UserService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UserService>(UserService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
98
backend/src/user/user.service.ts
Normal file
98
backend/src/user/user.service.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { UserModel } from './entities/user.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 사용자 정보 서비스
|
||||
* 프로필 조회/수정 등 사용자 정보 관련 비즈니스 로직
|
||||
*
|
||||
* TODO: 나중에 프로필 기능 필요시 아래 주석 해제
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@InjectRepository(UserModel)
|
||||
private readonly userRepository: Repository<UserModel>,
|
||||
) {}
|
||||
|
||||
// /**
|
||||
// * 사용자 ID로 조회
|
||||
// */
|
||||
// async findByUserId(userId: string): Promise<UserModel | null> {
|
||||
// return this.userRepository.findOne({
|
||||
// where: { userId, delDt: IsNull() },
|
||||
// });
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * 사용자 번호로 조회
|
||||
// */
|
||||
// async findByUserNo(userNo: number): Promise<UserModel | null> {
|
||||
// return this.userRepository.findOne({
|
||||
// where: { pkUserNo: userNo, delDt: IsNull() },
|
||||
// });
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * 내 프로필 조회
|
||||
// */
|
||||
// async getProfile(userNo: number): Promise<{
|
||||
// userNo: number;
|
||||
// userId: string;
|
||||
// userName: string;
|
||||
// userPhone: string;
|
||||
// userEmail: string;
|
||||
// userRole: string;
|
||||
// }> {
|
||||
// const user = await this.userRepository.findOne({
|
||||
// where: { pkUserNo: userNo, delDt: IsNull() },
|
||||
// });
|
||||
|
||||
// if (!user) {
|
||||
// throw new NotFoundException('사용자를 찾을 수 없습니다');
|
||||
// }
|
||||
|
||||
// return {
|
||||
// userNo: user.pkUserNo,
|
||||
// userId: user.userId,
|
||||
// userName: user.userName,
|
||||
// userPhone: user.userPhone,
|
||||
// userEmail: user.userEmail,
|
||||
// userRole: user.userRole,
|
||||
// };
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * 프로필 수정
|
||||
// */
|
||||
// async updateProfile(
|
||||
// userNo: number,
|
||||
// data: { userName?: string; userPhone?: string },
|
||||
// ): Promise<{ message: string }> {
|
||||
// const user = await this.userRepository.findOne({
|
||||
// where: { pkUserNo: userNo, delDt: IsNull() },
|
||||
// });
|
||||
|
||||
// if (!user) {
|
||||
// throw new NotFoundException('사용자를 찾을 수 없습니다');
|
||||
// }
|
||||
|
||||
// if (data.userName) user.userName = data.userName;
|
||||
// if (data.userPhone) user.userPhone = data.userPhone;
|
||||
|
||||
// await this.userRepository.save(user);
|
||||
|
||||
// return { message: '프로필이 수정되었습니다' };
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * 전체 사용자 목록 조회 (관리자용)
|
||||
// */
|
||||
// async findAll(): Promise<UserModel[]> {
|
||||
// return this.userRepository.find({
|
||||
// where: { delDt: IsNull() },
|
||||
// order: { regDt: 'DESC' },
|
||||
// });
|
||||
// }
|
||||
}
|
||||
Reference in New Issue
Block a user