Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
| 영역 | 기술 |
|------|------|
| Frontend | Vue 3, Vite, Pinia, Axios, 네이버지도 SDK, Tailwind CSS |
| Backend | Spring Boot 3, Spring Security (자체 JWT), PostgreSQL |
| AI Backend | Python 3.11, FastAPI, LangGraph, Codex API (OpenAI) |
| Backend | Spring Boot 3, Spring Security (자체 JWT), PostgreSQL, Redis, Gmail SMTP |
| AI Backend | Python 3.11, FastAPI, LangGraph, Claude API (Anthropic) |
| DB | Supabase (PostgreSQL + pgvector) |
| Infra | Cloud Run (backend + backend-ai 각각 독립 배포) |
| Batch | Spring Scheduler (국토교통부·생활안전지도 공공 API) |
Expand Down
10 changes: 8 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
| 영역 | 기술 |
|------|------|
| Frontend | Vue 3, Vite, Pinia, Axios, 네이버지도 SDK, Tailwind CSS |
| Backend | Spring Boot 3, Spring Security (자체 JWT), PostgreSQL |
| Backend | Spring Boot 3, Spring Security (자체 JWT), PostgreSQL, Redis, Gmail SMTP |
| AI Backend | Python 3.11, FastAPI, LangGraph, Claude API (Anthropic) |
| DB | Supabase (PostgreSQL + pgvector) |
| Infra | Cloud Run (backend + backend-ai 각각 독립 배포) |
Expand Down Expand Up @@ -51,5 +51,11 @@ cd backend-ai && uvicorn app.main:app --reload # 개발 서버
cd backend-ai && pytest tests/ # 테스트
```

## 로컬 개발 사전 조건

- Redis 실행 필수: `redis-server` (brew) 또는 `docker run -p 6379:6379 redis`
- `backend/.env` 파일 필요 (`.gitignore`에 포함, 팀원에게 별도 공유)
- `frontend/.env` 파일 필요 (`.gitignore`에 포함, 팀원에게 별도 공유)

## 문서
`docs/` 폴더에 01~12 번호 순서로 정렬되어 있습니다.
`docs/` 폴더에 01~13 번호 순서로 정렬되어 있습니다.
10 changes: 0 additions & 10 deletions backend/.env.example

This file was deleted.

19 changes: 19 additions & 0 deletions backend/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,25 @@
<optional>true</optional>
</dependency>

<!-- .env file support -->
<dependency>
<groupId>me.paulschwarz</groupId>
<artifactId>spring-dotenv</artifactId>
<version>3.0.0</version>
</dependency>

<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- Mail -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>

<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
public enum ErrorCode {
INVALID_REQUEST(HttpStatus.BAD_REQUEST, "요청 파라미터가 올바르지 않습니다."),
INVALID_BOUNDS(HttpStatus.BAD_REQUEST, "지도 범위 파라미터가 올바르지 않습니다."),
PROPERTY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 매물을 찾을 수 없습니다."),
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다."),
EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 가입된 이메일입니다."),
PROPERTY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 매물을 찾을 수 없습니다."),
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."),
EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."),
EMAIL_NOT_VERIFIED(HttpStatus.FORBIDDEN, "이메일 인증이 필요합니다."),
INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "인증 코드가 올바르지 않습니다."),
AI_SERVICE_UNAVAILABLE(HttpStatus.BAD_GATEWAY, "AI 서비스 응답이 없습니다."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다.");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// 인증 없이 허용
.requestMatchers("/api/v1/auth/signup", "/api/v1/auth/login", "/api/v1/auth/refresh").permitAll()
// 인가 없이 허용
.requestMatchers("/api/v1/auth/signup", "/api/v1/auth/login", "/api/v1/auth/refresh",
"/api/v1/auth/email/send", "/api/v1/auth/email/verify").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/properties/**", "/api/v1/map/**", "/api/v1/safety/**", "/api/v1/price-analysis").permitAll()
// 나머지는 로그인 필요
.anyRequest().authenticated()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
package com.ssafy.salmanhae.controller.auth;

import com.ssafy.salmanhae.model.dto.auth.LoginRequest;
import com.ssafy.salmanhae.model.dto.auth.LoginResponse;
import com.ssafy.salmanhae.model.dto.auth.SignupRequest;
import com.ssafy.salmanhae.model.dto.auth.*;
import com.ssafy.salmanhae.common.response.ApiResponse;
import com.ssafy.salmanhae.service.auth.AuthService;
import com.ssafy.salmanhae.service.auth.EmailVerificationService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
Expand All @@ -18,6 +19,7 @@
public class AuthController {

private final AuthService authService;
private final EmailVerificationService emailVerificationService;

@PostMapping("/signup")
public ResponseEntity<ApiResponse<Void>> signup(@RequestBody SignupRequest request) {
Expand All @@ -32,7 +34,26 @@ public ResponseEntity<ApiResponse<LoginResponse>> login(@RequestBody LoginReques
}

@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout() {
public ResponseEntity<ApiResponse<Void>> logout(@AuthenticationPrincipal User user) {
authService.logout(user.getEmail());
return ResponseEntity.ok(ApiResponse.ok());
}

@PostMapping("/refresh")
public ResponseEntity<ApiResponse<RefreshResponse>> refresh(@Valid @RequestBody RefreshRequest request) {
RefreshResponse response = authService.refresh(request.getRefreshToken());
return ResponseEntity.ok(ApiResponse.ok(response));
}

@PostMapping("/email/send")
public ResponseEntity<ApiResponse<Void>> sendVerificationEmail(@Valid @RequestBody EmailSendRequest request) {
emailVerificationService.sendCode(request.getEmail());
return ResponseEntity.ok(ApiResponse.ok());
}

@PostMapping("/email/verify")
public ResponseEntity<ApiResponse<Void>> verifyEmail(@Valid @RequestBody EmailVerifyRequest request) {
emailVerificationService.verifyCode(request.getEmail(), request.getCode());
return ResponseEntity.ok(ApiResponse.ok());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.ssafy.salmanhae.model.dto.auth;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;

@Getter
public class EmailSendRequest {
@NotBlank
@Email
private String email;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.ssafy.salmanhae.model.dto.auth;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Getter;

@Getter
public class EmailVerifyRequest {
@NotBlank
@Email
private String email;

@NotBlank
@Pattern(regexp = "^\\d{6}$")
private String code;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.ssafy.salmanhae.model.dto.auth;

import jakarta.validation.constraints.NotBlank;
import lombok.Getter;

@Getter
public class RefreshRequest {
@NotBlank
private String refreshToken;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.ssafy.salmanhae.model.dto.auth;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class RefreshResponse {
private String accessToken;
private String refreshToken;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,38 @@
import com.ssafy.salmanhae.common.exception.ErrorCode;
import com.ssafy.salmanhae.model.dao.auth.UserDao;
import com.ssafy.salmanhae.model.dto.auth.LoginResponse;
import com.ssafy.salmanhae.model.dto.auth.RefreshResponse;
import com.ssafy.salmanhae.model.dto.auth.User;
import com.ssafy.salmanhae.util.JwtUtil;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.time.Duration;

@Service
@RequiredArgsConstructor
public class AuthService {

private final UserDao userDao;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final StringRedisTemplate redisTemplate;

@Value("${jwt.refresh-token-expiration}")
private long refreshTokenExpiration;

private static final String REFRESH_PREFIX = "refresh:";

public void signup(String email, String password, String nickname) {
if (!Boolean.TRUE.toString().equals(redisTemplate.opsForValue().get("email:verified:" + email))) {
throw new ApiException(ErrorCode.EMAIL_NOT_VERIFIED);
}

User user = User.builder()
.email(email)
.password(passwordEncoder.encode(password))
Expand All @@ -33,9 +50,36 @@ public LoginResponse login(String email, String password) {
throw new ApiException(ErrorCode.UNAUTHORIZED);
}

return new LoginResponse(
jwtUtil.generateAccessToken(email),
jwtUtil.generateRefreshToken(email)
);
String accessToken = jwtUtil.generateAccessToken(email);
String refreshToken = jwtUtil.generateRefreshToken(email);
redisTemplate.opsForValue().set(REFRESH_PREFIX + email, refreshToken, Duration.ofMillis(refreshTokenExpiration));

return new LoginResponse(accessToken, refreshToken);
}

public RefreshResponse refresh(String refreshToken) {
String email;
try {
email = jwtUtil.getEmail(refreshToken);
} catch (ExpiredJwtException e) {
throw new ApiException(ErrorCode.EXPIRED_TOKEN);
} catch (JwtException | IllegalArgumentException e) {
throw new ApiException(ErrorCode.INVALID_TOKEN);
}

String stored = redisTemplate.opsForValue().get(REFRESH_PREFIX + email);
if (!refreshToken.equals(stored)) {
throw new ApiException(ErrorCode.INVALID_TOKEN);
}

String newAccessToken = jwtUtil.generateAccessToken(email);
String newRefreshToken = jwtUtil.generateRefreshToken(email);
redisTemplate.opsForValue().set(REFRESH_PREFIX + email, newRefreshToken, Duration.ofMillis(refreshTokenExpiration));

return new RefreshResponse(newAccessToken, newRefreshToken);
}

public void logout(String email) {
redisTemplate.delete(REFRESH_PREFIX + email);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.ssafy.salmanhae.service.auth;

import com.ssafy.salmanhae.common.exception.ApiException;
import com.ssafy.salmanhae.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;

import java.security.SecureRandom;
import java.time.Duration;

@Service
@RequiredArgsConstructor
public class EmailVerificationService {

private final StringRedisTemplate redisTemplate;
private final JavaMailSender mailSender;

@Value("${spring.mail.username}")
private String senderEmail;

private static final String VERIFY_PREFIX = "email:verify:";
private static final String VERIFIED_PREFIX = "email:verified:";

public void sendCode(String email) {
String code = String.format("%06d", new SecureRandom().nextInt(1_000_000));
redisTemplate.opsForValue().set(VERIFY_PREFIX + email, code, Duration.ofMinutes(5));

SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(senderEmail);
message.setTo(email);
message.setSubject("[살만해] 이메일 인증 코드");
message.setText("인증 코드: " + code + "\n5분 내에 입력해주세요.");
mailSender.send(message);
}

public void verifyCode(String email, String code) {
String saved = redisTemplate.opsForValue().get(VERIFY_PREFIX + email);

if (saved == null || !saved.equals(code)) {
throw new ApiException(ErrorCode.INVALID_VERIFICATION_CODE);
}

redisTemplate.delete(VERIFY_PREFIX + email);
redisTemplate.opsForValue().set(VERIFIED_PREFIX + email, "true", Duration.ofMinutes(10));
}
}
29 changes: 20 additions & 9 deletions backend/src/main/resources/application.properties
Original file line number Diff line number Diff line change
@@ -1,25 +1,36 @@
spring.application.name=salmanhae
spring.profiles.active=local

# DB
# Required: SUPABASE_DB_URL, SUPABASE_DB_USERNAME, SUPABASE_DB_PASSWORD
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=${SUPABASE_DB_URL}
spring.datasource.username=${SUPABASE_DB_USERNAME}
spring.datasource.password=${SUPABASE_DB_PASSWORD}

# MyBatis
mybatis.mapper-locations=classpath:mappers/**/*.xml
mybatis.type-aliases-package=com.ssafy.salmanhae.model.dto
mybatis.configuration.map-underscore-to-camel-case=true

# JWT
jwt.secret=${JWT_SECRET}
jwt.access-token-expiration=900000
jwt.refresh-token-expiration=604800000
# Required environment variables:
# SUPABASE_DB_URL - PostgreSQL JDBC connection string
# SUPABASE_DB_USERNAME - database username
# SUPABASE_DB_PASSWORD - database password
spring.datasource.url=${SUPABASE_DB_URL:}
spring.datasource.username=${SUPABASE_DB_USERNAME:}
spring.datasource.password=${SUPABASE_DB_PASSWORD:}
spring.datasource.driver-class-name=org.postgresql.Driver

# CORS
app.cors.allowed-origins=${APP_CORS_ALLOWED_ORIGINS:http://localhost:5173}

# Redis
spring.data.redis.host=${REDIS_HOST:localhost}
spring.data.redis.port=${REDIS_PORT:6379}

# Mail
spring.mail.host=smtp.gmail.com
spring.mail.port=587
spring.mail.username=${MAIL_USERNAME}
spring.mail.password=${MAIL_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true

# Backend AI internal API
ai.agent.base-url=${AI_AGENT_BASE_URL:http://localhost:8000}
Expand Down
Loading