This commit is contained in:
2025-12-09 17:02:27 +09:00
parent 26f8e1dab2
commit 83127da569
275 changed files with 139682 additions and 1 deletions

View File

@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View 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
View 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 {}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View 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);
}
}

View 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 {}

View 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: '비밀번호가 변경되었습니다',
};
}
}

View 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;
}

View 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';
};
}

View 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; // 비밀번호
}

View File

@@ -0,0 +1,21 @@
/**
* 비밀번호 재설정 응답 DTO
* 표준화된 응답 구조 정의
*
* @export
* @class ResetPasswordResponseDto
* @typedef {ResetPasswordResponseDto}
*/
export class ResetPasswordResponseDto {
/**
* 응답 메시지
* @type {string}
*/
message: string;
/**
* 임시 비밀번호 (개발 환경에서만 반환, 실무에서는 SMS 발송)
* @type {string}
*/
tempPassword?: string;
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,13 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
/**
* 회원가입 인증번호 발송 DTO
*
* @export
* @class SendSignupCodeDto
*/
export class SendSignupCodeDto {
@IsEmail()
@IsNotEmpty()
userEmail: string;
}

View 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;
}

View 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; // 사업자등록번호
}

View 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;
}

View 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;
}

View 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;
}

View 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();
});
});

View 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) {}
}

View 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 {}

View 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();
});
});

View File

@@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class CommonService {}

View 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;

View 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}))`;
}

View 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;

View 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;
}

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,6 @@
// 계정 상태 Enum
export enum AccountStatusType {
ACTIVE = "ACTIVE", // 정상
INACTIVE = "INACTIVE", // 비활성
SUSPENDED = "SUSPENDED", // 정지
}

View File

@@ -0,0 +1,5 @@
// 개체 타입 Enum
export enum AnimalType {
COW = 'COW', // 개체
KPN = 'KPN', // KPN
}

View File

@@ -0,0 +1,12 @@
/**
* 분석 현황 상태 값 Enum
*
* @export
* @enum {number}
*/
export enum AnlysStatType {
MATCH = '친자일치',
MISMATCH = '친자불일치',
IMPOSSIBLE = '분석불가',
NO_HISTORY = '이력제부재',
}

View File

@@ -0,0 +1,13 @@
/**
* 사육/도태 추천 타입 Enum
*
* @export
* @enum {string}
*/
export enum BreedingRecommendationType {
/** 사육 추천 */
BREED = '사육추천',
/** 도태 추천 */
CULL = '도태추천',
}

View File

@@ -0,0 +1,7 @@
// 개체 번식 타입 Enum
export enum CowReproType {
DONOR = "공란우",
RECIPIENT = "수란우",
AI = "인공수정",
CULL = "도태대상",
}

View File

@@ -0,0 +1,7 @@
// 개체 상태 Enum
export enum CowStatusType {
NORMAL = "정상",
DEAD = "폐사",
SLAUGHTER = "도축",
SALE = "매각",
}

View 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 = '마커정보',
}

View File

@@ -0,0 +1,11 @@
/**
* 사용자 타입 구분 Enum
*
* @export
* @enum {number}
*/
export enum UserSeType {
FARM = 'FARM', // 농가
CNSLT = 'CNSLT', // 컨설턴트
ORGAN = 'ORGAN', // 기관담당자
}

View 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;
},
);

View 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);

View 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;
},
);

View 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;
}

View 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);
}
}

View 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';
}
}
}

View 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;
}
}

View 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}`,
);
},
}),
);
}
}

View 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(),
})),
);
}
}

View 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 {}

View 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',
};
}
}

View 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';
}

View 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);
}
}

View 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 {}

View 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);
}
}

View 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; // 랭킹 조건
}

View 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;
}

View 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);
}
}

View 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 {}

View 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,
},
};
}
}

View 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;
}

View 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;
}

View 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);
}
}

View 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 {}

View 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];
}
}

View 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;
}

View 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;
}

View 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);
}
}

View 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 {}

File diff suppressed because it is too large Load Diff

View 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;
}

View 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;
}

View 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) {}

View 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
}

View 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);
}
}

View 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 {}

View 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
View 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();

View 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;
}

View 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);
}
}

View 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 {}

View 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);
}
}

View 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 {}

View 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 {}

View 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>
`,
});
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { FilterEngineService } from './filter-engine.service';
@Module({
providers: [FilterEngineService],
exports: [FilterEngineService],
})
export class FilterEngineModule {}

View 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
});
});
});

View 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;
}
}

View 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;
}

View 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 {}

View 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 {}

View 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;
}
}

View 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';
}

View 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();
});
});

View 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 - 특정 사용자 조회 (관리자용)
}

View 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 {}

View 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();
});
});

View 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' },
// });
// }
}