diff --git a/build.gradle b/build.gradle index 2632587..81f500d 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,7 @@ dependencies { implementation("org.bouncycastle:bcprov-jdk18on:1.78.1") implementation 'io.github.cdimascio:java-dotenv:5.2.2' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' + implementation 'org.springframework.boot:spring-boot-starter-websocket' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j:8.0.33' diff --git a/src/main/java/com/chaineeproject/chainee/auth/AuthService.java b/src/main/java/com/chaineeproject/chainee/auth/AuthService.java index 05b79ac..ade96eb 100644 --- a/src/main/java/com/chaineeproject/chainee/auth/AuthService.java +++ b/src/main/java/com/chaineeproject/chainee/auth/AuthService.java @@ -35,11 +35,14 @@ public AuthTokens issueFor(User u) { long accessTtl = jwtProps.accessTtl().toSeconds(); long refreshTtl = jwtProps.refreshTtl().toSeconds(); + // ✅ DID 문자열 존재 여부가 아니라 didVerified 플래그 사용 String access = jwtService.createAccessToken( - u.getId(), u.getEmail(), + u.getId(), + u.getEmail(), u.isKycVerified(), - u.getDid()!=null && !u.getDid().isBlank(), - accessTtl); + u.isDidVerified(), // <-- 여기만 바뀜 + accessTtl + ); String refresh = jwtService.createRefreshToken(u.getId(), refreshTtl); @@ -53,8 +56,10 @@ public AuthTokens issueFor(User u) { .build(); refreshRepo.save(rt); - return new AuthTokens(access, now.plusSeconds(accessTtl).getEpochSecond(), - refresh, now.plusSeconds(refreshTtl).getEpochSecond()); + return new AuthTokens( + access, now.plusSeconds(accessTtl).getEpochSecond(), + refresh, now.plusSeconds(refreshTtl).getEpochSecond() + ); } @Transactional diff --git a/src/main/java/com/chaineeproject/chainee/config/SecurityConfig.java b/src/main/java/com/chaineeproject/chainee/config/SecurityConfig.java index ef3f7d2..c3b8aca 100644 --- a/src/main/java/com/chaineeproject/chainee/config/SecurityConfig.java +++ b/src/main/java/com/chaineeproject/chainee/config/SecurityConfig.java @@ -26,10 +26,9 @@ import org.springframework.web.filter.CorsFilter; import java.security.interfaces.RSAPublicKey; -import java.util.List; @Configuration -@EnableConfigurationProperties(AppProperties.class) // ✅ AppProperties 바인딩 활성화 +@EnableConfigurationProperties(AppProperties.class) public class SecurityConfig { private final CustomOauth2UserService customOauth2UserService; @@ -52,15 +51,18 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth + // Swagger / H2 / 로그인 등 공개 .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() .requestMatchers("/h2-console/**").permitAll() .requestMatchers("/error").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/login", "/oauth2/**").permitAll() - .requestMatchers("/api/auth/refresh", "/api/auth/logout").permitAll() - .requestMatchers("/api/auth/me").authenticated() + // WS 핸드셰이크는 열어두고, STOMP CONNECT 에서 JWT 검증 + .requestMatchers("/ws/**").permitAll() + // 인증 필요한 API + .requestMatchers("/api/auth/me").authenticated() .requestMatchers("/api/kyc/**").authenticated() .requestMatchers("/api/did/**").authenticated() .requestMatchers("/api/resumes/**").authenticated() @@ -72,6 +74,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, .requestMatchers(HttpMethod.GET, "/api/notifications", "/api/notifications/unread-count").authenticated() .requestMatchers(HttpMethod.PATCH,"/api/notifications/**").authenticated() + // ✅ 채팅 REST는 인증 필수 + .requestMatchers("/api/chat/**").authenticated() + + // 나머지 .requestMatchers("/api/**").permitAll() .anyRequest().permitAll() ) @@ -84,7 +90,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, .failureHandler(customOAuth2FailureHandler) ) - .oauth2ResourceServer(oauth -> oauth.jwt(jwt -> { })) + .oauth2ResourceServer(oauth -> oauth.jwt(jwt -> {})) // RS256 JWT 검증 .exceptionHandling(ex -> ex .authenticationEntryPoint((req, res, e) -> { res.setContentType("application/json"); @@ -119,15 +125,12 @@ public CookieOAuth2AuthorizationRequestRepository cookieOAuth2AuthorizationReque return new CookieOAuth2AuthorizationRequestRepository("chainee.store"); } - /** ✅ CORS를 yml의 frontend-baseurls 기반으로 구성 */ @Bean public CorsFilter corsFilter(AppProperties props) { CorsConfiguration cfg = new CorsConfiguration(); - for (String base : props.effectiveBases()) { - cfg.addAllowedOrigin(base); // 정확한 Origin만 허용 + cfg.addAllowedOrigin(base); } - cfg.addAllowedHeader("*"); cfg.addAllowedMethod("*"); cfg.setAllowCredentials(true); diff --git a/src/main/java/com/chaineeproject/chainee/config/WebSocketConfig.java b/src/main/java/com/chaineeproject/chainee/config/WebSocketConfig.java new file mode 100644 index 0000000..6aca19b --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/config/WebSocketConfig.java @@ -0,0 +1,36 @@ +package com.chaineeproject.chainee.config; + +import com.chaineeproject.chainee.security.ws.HttpHandshakeJwtInterceptor; +import com.chaineeproject.chainee.security.ws.StompJwtChannelInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.*; + +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final StompJwtChannelInterceptor stompJwtChannelInterceptor; + + @Override + public void registerStompEndpoints(StompEndpointRegistry reg) { + reg.addEndpoint("/ws") + .setAllowedOriginPatterns("*") + .addInterceptors(new HttpHandshakeJwtInterceptor()); // 쿼리스트링 token= 지원(옵션) + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(stompJwtChannelInterceptor); // CONNECT의 Authorization: Bearer 처리 + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker("/topic", "/queue"); + config.setApplicationDestinationPrefixes("/app"); + config.setUserDestinationPrefix("/user"); + } +} diff --git a/src/main/java/com/chaineeproject/chainee/controller/ChatController.java b/src/main/java/com/chaineeproject/chainee/controller/ChatController.java new file mode 100644 index 0000000..a4f7008 --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/controller/ChatController.java @@ -0,0 +1,66 @@ +package com.chaineeproject.chainee.controller; + +import com.chaineeproject.chainee.entity.Conversation; +import com.chaineeproject.chainee.entity.Message; +import com.chaineeproject.chainee.service.ChatService; +import com.chaineeproject.chainee.security.SecurityUtils; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/chat") +public class ChatController { + + private final ChatService chatService; + + public record StartReq(Long postId, Long applicantId) {} + public record SendReq(String content, String attachmentUrl) {} + + @PostMapping("/conversations") + @Operation(summary = "대화 시작(구인자 전용)") + public ResponseEntity start(@AuthenticationPrincipal Jwt jwt, + @RequestBody StartReq req) { + Long uid = SecurityUtils.uidOrNull(jwt); + if (uid == null) return ResponseEntity.status(401).build(); + var conv = chatService.startConversation(req.postId(), req.applicantId(), uid); + return ResponseEntity.ok(conv); + } + + @GetMapping("/conversations/{conversationId}/messages") + @Operation(summary = "메시지 조회") + public ResponseEntity> messages(@AuthenticationPrincipal Jwt jwt, + @PathVariable Long conversationId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size) { + Long uid = SecurityUtils.uidOrNull(jwt); + if (uid == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(chatService.getMessages(conversationId, uid, page, size)); + } + + @PostMapping("/conversations/{conversationId}/messages") + @Operation(summary = "메시지 전송") + public ResponseEntity send(@AuthenticationPrincipal Jwt jwt, + @PathVariable Long conversationId, + @RequestBody SendReq req) { + Long uid = SecurityUtils.uidOrNull(jwt); + if (uid == null) return ResponseEntity.status(401).build(); + var saved = chatService.sendMessage(conversationId, uid, req.content(), req.attachmentUrl()); + return ResponseEntity.ok(saved); + } + + @PostMapping("/conversations/{conversationId}/read") + @Operation(summary = "읽음 처리") + public ResponseEntity read(@AuthenticationPrincipal Jwt jwt, + @PathVariable Long conversationId) { + Long uid = SecurityUtils.uidOrNull(jwt); + if (uid == null) return ResponseEntity.status(401).build(); + chatService.markRead(conversationId, uid); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/chaineeproject/chainee/controller/ChatWsController.java b/src/main/java/com/chaineeproject/chainee/controller/ChatWsController.java new file mode 100644 index 0000000..47ee9c5 --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/controller/ChatWsController.java @@ -0,0 +1,35 @@ +package com.chaineeproject.chainee.controller; + +import com.chaineeproject.chainee.entity.Message; +import com.chaineeproject.chainee.security.ws.WsPrincipal; +import com.chaineeproject.chainee.service.ChatService; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Controller; + +import java.security.Principal; + +@Controller +@RequiredArgsConstructor +public class ChatWsController { + + private final ChatService chatService; + private final SimpMessagingTemplate messagingTemplate; + + public record WsSendPayload(Long conversationId, String content, String attachmentUrl) {} + + @MessageMapping("/chat.send") + public void send(@Payload WsSendPayload p, Principal principal) { + Long senderId = extractUid(principal); + Message saved = chatService.sendMessage(p.conversationId(), senderId, p.content(), p.attachmentUrl()); + messagingTemplate.convertAndSend("/topic/conversations/" + p.conversationId(), saved); + } + + private Long extractUid(Principal principal) { + if (principal instanceof WsPrincipal ws) return ws.userId(); + // 안전망: 그래도 없으면 거부 + throw new SecurityException("NO_AUTH"); + } +} diff --git a/src/main/java/com/chaineeproject/chainee/controller/DidController.java b/src/main/java/com/chaineeproject/chainee/controller/DidController.java deleted file mode 100644 index 8450c8f..0000000 --- a/src/main/java/com/chaineeproject/chainee/controller/DidController.java +++ /dev/null @@ -1,270 +0,0 @@ -package com.chaineeproject.chainee.controller; - -import com.chaineeproject.chainee.auth.AuthService; -import com.chaineeproject.chainee.did.dto.DidNonceResponse; -import com.chaineeproject.chainee.did.dto.DidVerifyRequest; -import com.chaineeproject.chainee.did.dto.DidVerifyResponse; -import com.chaineeproject.chainee.entity.User; -import com.chaineeproject.chainee.jwt.JwtProperties; -import com.chaineeproject.chainee.repository.UserRepository; -import com.chaineeproject.chainee.security.oauth.CookieUtil; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; -import org.bouncycastle.crypto.signers.Ed25519Signer; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.web.bind.annotation.*; - -import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.time.LocalDateTime; -import java.util.Base64; -import java.util.Map; - -@RestController -@RequestMapping("/api/did") -@RequiredArgsConstructor -@Tag(name = "DID", description = "DID(지갑) 연동/검증 API") -@SecurityRequirement(name = "bearerAuth") -public class DidController { - - private final UserRepository users; - private final AuthService authService; - private final JwtProperties jwtProps; - - private static final SecureRandom RAND = new SecureRandom(); - - @Operation( - summary = "DID용 서명 nonce 발급", - description = "지갑에서 서명할 랜덤 nonce를 발급합니다. 이 nonce를 그대로 메시지로 서명한 뒤 /api/did/verify에 제출하세요.", - responses = @ApiResponse( - responseCode = "200", - description = "발급 성공", - content = @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = DidNonceResponse.class), - examples = @ExampleObject( - name = "예시", - value = """ - { "nonce": "6y7B8yB0W2bN8asbO5zQwH1P7C0s3-cUZG2p1KcB9ko", "expiresInSec": 600 } - """ - ) - ) - ) - ) - @PostMapping(value = "/nonce", produces = MediaType.APPLICATION_JSON_VALUE) - public DidNonceResponse nonce(@AuthenticationPrincipal Jwt jwt) { - Long uid = Long.valueOf(jwt.getSubject()); - User me = users.findById(uid).orElseThrow(); - - String nonce = genNonce(); - me.setDidNonce(nonce); - me.setDidNonceExpiresAt(LocalDateTime.now().plusMinutes(10)); - users.save(me); - - return new DidNonceResponse(nonce, 600); - } - - @Operation( - summary = "DID 검증", - description = """ - 1) /api/did/nonce로 받은 nonce를 그대로 메시지로 사용해, Solana(Ed25519) 개인키로 서명하세요. - 2) 지갑 주소(Base58 PublicKey), DID, 서명(Base58)을 본 API에 제출하면 서명을 검증하고 DID를 계정에 연결합니다. - 3) 성공 시 did=true가 반영된 새로운 access/refresh 토큰을 발급합니다. - """, - // ★ 여기! alias 없이 완전수식명 사용 - requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = DidVerifyRequest.class), - examples = @ExampleObject( - name = "성공 예시", - value = """ - { - "did": "did:sol:6h5Z5o3P8TQfYd1wL2S3R8mV9PqKj8w2uB3F", - "address": "3ypq3qGxhw6sSpH3zv3C8q3vG6iZ3vR3FJ7mJYwRkQ6o", - "signatureBase58": "5dVjE3gD4mUXo8wPswj7H7w8f8n3zq3b2b...WmLcp" - } - """ - ) - ) - ), - responses = { - @ApiResponse( - responseCode = "200", - description = "검증 성공", - content = @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = DidVerifyResponse.class), - examples = @ExampleObject( - name = "성공 응답", - value = """ - { - "success": true, - "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", - "accessExp": 1760091753, - "refreshExp": 1761300453 - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "400", - description = "검증 실패(Nonce 누락/만료/서명 불일치 등)", - content = @Content( - mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(example = """ - { "success": false, "messageCode": "SIGNATURE_INVALID" } - """) - ) - ) - } - ) - @PostMapping(value = "/verify", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity verify(@AuthenticationPrincipal Jwt jwt, - @RequestBody DidVerifyRequest req, - HttpServletRequest request, - HttpServletResponse response) { - Long uid = Long.valueOf(jwt.getSubject()); - User me = users.findById(uid).orElseThrow(); - - // 1) Nonce 유효성 - if (me.getDidNonce() == null || me.getDidNonceExpiresAt() == null) { - return ResponseEntity.badRequest().body(Map.of("success", false, "messageCode", "NONCE_MISSING")); - } - if (LocalDateTime.now().isAfter(me.getDidNonceExpiresAt())) { - return ResponseEntity.badRequest().body(Map.of("success", false, "messageCode", "NONCE_EXPIRED")); - } - - // 2) 서명 검증 - boolean ok = verifySolanaSignature(req.address(), req.signatureBase58(), me.getDidNonce()); - if (!ok) { - return ResponseEntity.badRequest().body(Map.of("success", false, "messageCode", "SIGNATURE_INVALID")); - } - - // 3) DID 저장 + Nonce 정리 - me.setDid(req.did()); - me.setDidNonce(null); - me.setDidNonceExpiresAt(null); - users.save(me); - - // 4) 새 토큰 세트 재발급 - var tokens = authService.issueFor(me); - - // 5) Refresh 쿠키 교체 - int maxAge = (int) jwtProps.refreshTtl().toSeconds(); - String cookieDomain = cookieDomainFor(request); - CookieUtil.addCookie( - response, - jwtProps.refreshCookieName(), - tokens.refreshToken(), - cookieDomain, - true, - Boolean.TRUE.equals(jwtProps.cookie().secure()), - jwtProps.cookie().sameSite(), - maxAge - ); - - // 6) Access 바디 반환 - return ResponseEntity.ok(new DidVerifyResponse( - true, - tokens.accessToken(), - tokens.accessExpEpochSec(), - tokens.refreshExpEpochSec() - )); - } - - /* ===================== 유틸 ===================== */ - - private String genNonce() { - byte[] buf = new byte[24]; - RAND.nextBytes(buf); - return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); - } - - private String cookieDomainFor(HttpServletRequest req) { - String host = req.getServerName(); - if (host != null && (host.equals("chainee.store") || host.endsWith(".chainee.store"))) { - return "chainee.store"; - } - return null; - } - - private boolean verifySolanaSignature(String addressBase58, String sigBase58, String message) { - try { - byte[] pub = Base58.decode(addressBase58); // 32 bytes - byte[] sig = Base58.decode(sigBase58); // 64 bytes - byte[] msg = message.getBytes(StandardCharsets.UTF_8); - - if (pub == null || pub.length != 32) return false; - if (sig == null || sig.length != 64) return false; - - Ed25519PublicKeyParameters pk = new Ed25519PublicKeyParameters(pub, 0); - Ed25519Signer verifier = new Ed25519Signer(); - verifier.init(false, pk); - verifier.update(msg, 0, msg.length); - return verifier.verifySignature(sig); - } catch (Exception e) { - return false; - } - } - - private static final class Base58 { - private static final String ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; - private static final int[] INDEXES = new int[128]; - static { - for (int i = 0; i < INDEXES.length; i++) INDEXES[i] = -1; - for (int i = 0; i < ALPHABET.length(); i++) INDEXES[ALPHABET.charAt(i)] = i; - } - - static byte[] decode(String input) { - if (input == null || input.isEmpty()) return new byte[0]; - - int zeros = 0; - int length = 0; - for (int i = 0; i < input.length() && input.charAt(i) == '1'; i++) zeros++; - - byte[] b58 = new byte[input.length()]; - for (int i = 0; i < input.length(); i++) { - char c = input.charAt(i); - if (c >= 128 || INDEXES[c] == -1) throw new IllegalArgumentException("Invalid Base58 character: " + c); - b58[i] = (byte) INDEXES[c]; - } - - byte[] temp = new byte[input.length()]; - for (int i = 0; i < input.length(); i++) { - int carry = b58[i] & 0xFF; - int j = input.length() - 1; - for (; (carry != 0 || j >= length) && j >= 0; j--) { - carry += 58 * (temp[j] & 0xFF); - temp[j] = (byte) (carry % 256); - carry /= 256; - } - length = input.length() - 1 - j; - } - - int start = input.length() - length; - while (start < input.length() && temp[start] == 0) start++; - - int resultLen = zeros + (input.length() - start); - byte[] decoded = new byte[resultLen]; - for (int i = 0; i < zeros; i++) decoded[i] = 0; - int idx = zeros; - for (int i = start; i < input.length(); i++) decoded[idx++] = temp[i]; - return decoded; - } - } -} diff --git a/src/main/java/com/chaineeproject/chainee/controller/JobPostController.java b/src/main/java/com/chaineeproject/chainee/controller/JobPostController.java index b85a270..e1e2654 100644 --- a/src/main/java/com/chaineeproject/chainee/controller/JobPostController.java +++ b/src/main/java/com/chaineeproject/chainee/controller/JobPostController.java @@ -1,3 +1,4 @@ +// src/main/java/com/chaineeproject/chainee/controller/JobPostController.java package com.chaineeproject.chainee.controller; import com.chaineeproject.chainee.dto.JobPostRequest; @@ -34,7 +35,7 @@ public class JobPostController { @PostMapping @Operation( summary = "구인 공고 등록", - description = "작성자의 DID를 바탕으로 구인 공고를 생성합니다.", + description = "작성자 ID를 바탕으로 구인 공고를 생성합니다.", requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( required = true, content = @Content( @@ -42,7 +43,7 @@ public class JobPostController { schema = @Schema(implementation = JobPostRequest.class), examples = @ExampleObject(value = """ { - "authorDid": "did:example:abc123", + "authorId": 19, "title": "백엔드 개발자 구합니다", "description": "Spring Boot 프로젝트 개발자 모집", "payment": 1500000, @@ -56,7 +57,7 @@ public class JobPostController { ) public ResponseEntity createJobPost(@RequestBody JobPostRequest request) { try { - User author = userRepository.findByDid(request.authorDid()) + User author = userRepository.findById(request.authorId()) .orElseThrow(() -> new ApplicationException(ErrorCode.USER_NOT_FOUND)); String skillsJson = objectMapper.writeValueAsString(request.requiredSkills()); @@ -78,7 +79,7 @@ public ResponseEntity createJobPost(@RequestBody JobPostRequest } catch (JsonProcessingException e) { log.warn("requiredSkills 변환 실패", e); - throw new ApplicationException(ErrorCode.INVALID_REQUEST); // → 400 + throw new ApplicationException(ErrorCode.INVALID_REQUEST); } } } diff --git a/src/main/java/com/chaineeproject/chainee/did/DidController.java b/src/main/java/com/chaineeproject/chainee/did/DidController.java new file mode 100644 index 0000000..a93b9fd --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/did/DidController.java @@ -0,0 +1,83 @@ +package com.chaineeproject.chainee.did; + +import com.chaineeproject.chainee.did.dto.DidVerifyRequest; +import com.chaineeproject.chainee.did.dto.DidVerifyResponse; +import com.chaineeproject.chainee.entity.User; +import com.chaineeproject.chainee.repository.UserRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.*; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/did") +@RequiredArgsConstructor +@Tag(name = "DID", description = "프론트 검증 결과를 반영해 didVerified 상태를 업데이트") +@SecurityRequirement(name = "bearerAuth") +public class DidController { + + private final DidService didService; + private final UserRepository userRepository; + + private User me(Jwt jwt) { + Long id = Long.valueOf(jwt.getSubject()); + return userRepository.findById(id).orElseThrow(); + } + + @Operation( + summary = "DID 검증 상태 반영", + description = "프론트에서 DID 검증을 끝낸 뒤 결과를 서버에 반영합니다. (문자열 DID는 저장하지 않음)", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = DidVerifyRequest.class), + examples = { + @ExampleObject(name="검증 완료로 반영", value = "{ \"verified\": true }"), + @ExampleObject(name="검증 해제로 반영", value = "{ \"verified\": false }") + } + ) + ), + responses = @ApiResponse( + responseCode = "200", description = "반영 결과", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = DidVerifyResponse.class), + examples = { + @ExampleObject(name="완료 응답", value = """ + { + "success": true, + "didVerified": true, + "didVerifiedAt": "2025-10-14T21:35:12.345" + } + """), + @ExampleObject(name="해제 응답", value = """ + { + "success": true, + "didVerified": false, + "didVerifiedAt": null + } + """) + } + ) + ) + ) + @PostMapping(value = "/verify", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity verify(@AuthenticationPrincipal Jwt jwt, + @RequestBody @Valid DidVerifyRequest req) { + var updated = didService.updateDidVerified(me(jwt), req.verified()); + return ResponseEntity.ok(new DidVerifyResponse( + true, + updated.isDidVerified(), + updated.getDidVerifiedAt() != null ? updated.getDidVerifiedAt().toString() : null + )); + } +} diff --git a/src/main/java/com/chaineeproject/chainee/did/DidService.java b/src/main/java/com/chaineeproject/chainee/did/DidService.java new file mode 100644 index 0000000..33a57e3 --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/did/DidService.java @@ -0,0 +1,22 @@ +// src/main/java/com/chaineeproject/chainee/did/DidService.java +package com.chaineeproject.chainee.did; + +import com.chaineeproject.chainee.entity.User; +import com.chaineeproject.chainee.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DidService { + + private final UserRepository userRepository; + + @Transactional + public User updateDidVerified(User me, boolean verified) { + if (verified) me.markDidVerified(); + else me.unmarkDidVerified(); + return userRepository.save(me); + } +} diff --git a/src/main/java/com/chaineeproject/chainee/did/dto/DidNonceResponse.java b/src/main/java/com/chaineeproject/chainee/did/dto/DidNonceResponse.java deleted file mode 100644 index 19bb2de..0000000 --- a/src/main/java/com/chaineeproject/chainee/did/dto/DidNonceResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.chaineeproject.chainee.did.dto; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(name = "DidNonceResponse", description = "DID 서명을 위한 nonce 응답") -public record DidNonceResponse( - @Schema(description = "서명할 nonce (URL-safe Base64, padding 없음)", - example = "6y7B8yB0W2bN8asbO5zQwH1P7C0s3-cUZG2p1KcB9ko") - String nonce, - - @Schema(description = "nonce 만료까지 남은 초", example = "600") - int expiresInSec -) {} diff --git a/src/main/java/com/chaineeproject/chainee/did/dto/DidVerifyRequest.java b/src/main/java/com/chaineeproject/chainee/did/dto/DidVerifyRequest.java index 19e26d6..3d62be7 100644 --- a/src/main/java/com/chaineeproject/chainee/did/dto/DidVerifyRequest.java +++ b/src/main/java/com/chaineeproject/chainee/did/dto/DidVerifyRequest.java @@ -1,15 +1,9 @@ package com.chaineeproject.chainee.did.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; -@Schema(name = "DidVerifyRequest", description = "DID 검증 요청 바디") public record DidVerifyRequest( - @Schema(description = "연동할 DID 식별자", example = "did:sol:6h5Z5o3P8TQfYd1wL2S3R8mV9PqKj8w2uB3F") - String did, - - @Schema(description = "지갑 주소 (Base58 PublicKey, 32바이트)", example = "3ypq3qGxhw6sSpH3zv3C8q3vG6iZ3vR3FJ7mJYwRkQ6o") - String address, - - @Schema(description = "nonce 원문에 대한 서명 (Base58, 64바이트)", example = "5dVjE3gD4mUXo8wPswj7H7w8f8n3zq3b2b...WmLcp") - String signatureBase58 + @Schema(description = "프론트에서 검증한 DID 결과(true=검증완료로 반영, false=해제)", example = "true") + @NotNull Boolean verified ) {} diff --git a/src/main/java/com/chaineeproject/chainee/did/dto/DidVerifyResponse.java b/src/main/java/com/chaineeproject/chainee/did/dto/DidVerifyResponse.java index 6c29332..69304d2 100644 --- a/src/main/java/com/chaineeproject/chainee/did/dto/DidVerifyResponse.java +++ b/src/main/java/com/chaineeproject/chainee/did/dto/DidVerifyResponse.java @@ -2,17 +2,11 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(name = "DidVerifyResponse", description = "DID 검증 성공 시 반환") public record DidVerifyResponse( - @Schema(description = "성공 여부", example = "true") + @Schema(description = "요청 처리 성공 여부", example = "true") boolean success, - - @Schema(description = "새 Access 토큰(JWT)", example = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...") - String accessToken, - - @Schema(description = "Access 토큰 만료 EpochSec", example = "1760091753") - Long accessExp, - - @Schema(description = "Refresh 토큰 만료 EpochSec", example = "1761300453") - Long refreshExp + @Schema(description = "현재 DID 검증 상태", example = "true") + boolean didVerified, + @Schema(description = "검증 완료 시각(ISO-8601) - 해제 시 null", example = "2025-10-14T21:35:12.345") + String didVerifiedAt ) {} diff --git a/src/main/java/com/chaineeproject/chainee/dto/JobApplicationRequest.java b/src/main/java/com/chaineeproject/chainee/dto/JobApplicationRequest.java index 9d123fa..7e9295f 100644 --- a/src/main/java/com/chaineeproject/chainee/dto/JobApplicationRequest.java +++ b/src/main/java/com/chaineeproject/chainee/dto/JobApplicationRequest.java @@ -4,8 +4,8 @@ @Schema(description = "채용 공고 지원 요청 DTO") public record JobApplicationRequest( - @Schema(description = "지원자의 DID (Decentralized Identifier)", example = "did:example:123456789abcdefghi") - String applicantDid, + @Schema(description = "지원자의 ID", example = "19") + Long applicantId, @Schema(description = "지원자가 선택한 이력서 ID", example = "42") Long resumeId diff --git a/src/main/java/com/chaineeproject/chainee/dto/JobPostRequest.java b/src/main/java/com/chaineeproject/chainee/dto/JobPostRequest.java index 1384ef8..236a962 100644 --- a/src/main/java/com/chaineeproject/chainee/dto/JobPostRequest.java +++ b/src/main/java/com/chaineeproject/chainee/dto/JobPostRequest.java @@ -8,8 +8,8 @@ @Schema(description = "구인 공고 등록 요청 DTO") public record JobPostRequest( - @Schema(description = "작성자 DID", example = "did:example:abc123") - String authorDid, + @Schema(description = "작성자 ID", example = "19") + Long authorId, @Schema(description = "공고 제목", example = "백엔드 개발자 구합니다") String title, diff --git a/src/main/java/com/chaineeproject/chainee/dto/ResumeCreateRequest.java b/src/main/java/com/chaineeproject/chainee/dto/ResumeCreateRequest.java index b21634d..f456175 100644 --- a/src/main/java/com/chaineeproject/chainee/dto/ResumeCreateRequest.java +++ b/src/main/java/com/chaineeproject/chainee/dto/ResumeCreateRequest.java @@ -6,8 +6,8 @@ @Schema(name = "ResumeCreateRequest", description = "이력서 생성 요청") public record ResumeCreateRequest( - @Schema(description = "지원자 DID", example = "did:example:xyz789") - @NotBlank String applicantDid, + @Schema(description = "지원자 ID", example = "19") + @NotBlank Long applicantId, @Schema(description = "이력서 제목", example = "프론트엔드 개발자 이력서") @NotBlank String title, diff --git a/src/main/java/com/chaineeproject/chainee/dto/UserPreview.java b/src/main/java/com/chaineeproject/chainee/dto/UserPreview.java index b586eda..9b7870b 100644 --- a/src/main/java/com/chaineeproject/chainee/dto/UserPreview.java +++ b/src/main/java/com/chaineeproject/chainee/dto/UserPreview.java @@ -8,7 +8,6 @@ public record UserPreview( Long id, String name, String profileImageUrl, - String did, @Schema(description = "사용자 포지션 목록", example = "[\"Frontend\", \"UI/UX\"]") List positions ) {} diff --git a/src/main/java/com/chaineeproject/chainee/entity/Conversation.java b/src/main/java/com/chaineeproject/chainee/entity/Conversation.java new file mode 100644 index 0000000..8ffa5c9 --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/entity/Conversation.java @@ -0,0 +1,34 @@ +// Conversation.java +package com.chaineeproject.chainee.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Getter @Setter +@NoArgsConstructor @AllArgsConstructor @Builder +@Table(name = "conversation", + uniqueConstraints = @UniqueConstraint(name = "uk_conv_post_applicant", + columnNames = {"post_id","applicant_id"})) +public class Conversation { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 어떤 공고에서 시작된 대화인지(구인자 식별을 위해) + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_id", nullable = false) + private JobPost post; // post.getAuthor()가 구인자 + + // 구직자 + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "applicant_id", nullable = false) + private User applicant; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @PrePersist void onCreate() { this.createdAt = LocalDateTime.now(); } + + public Long getEmployerId() { return post.getAuthor().getId(); } + public Long getApplicantId() { return applicant.getId(); } +} diff --git a/src/main/java/com/chaineeproject/chainee/entity/Message.java b/src/main/java/com/chaineeproject/chainee/entity/Message.java new file mode 100644 index 0000000..cff3ab1 --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/entity/Message.java @@ -0,0 +1,40 @@ +// Message.java +package com.chaineeproject.chainee.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Getter @Setter +@NoArgsConstructor @AllArgsConstructor @Builder +@Table(name = "message", indexes = { + @Index(name = "idx_msg_conv_created", columnList = "conversation_id, created_at") +}) +public class Message { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name="conversation_id", nullable=false) + private Conversation conversation; + + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name="sender_id", nullable=false) + private User sender; + + @Column(columnDefinition = "TEXT") + private String content; // 텍스트 + + private String attachmentUrl; // (옵션) 파일/이미지 업로드 후 URL + + @Column(name="created_at", nullable=false) + private LocalDateTime createdAt; + + @Column(name="read_by_applicant", nullable=false) + private boolean readByApplicant; + + @Column(name="read_by_employer", nullable=false) + private boolean readByEmployer; + + @PrePersist void onCreate() { this.createdAt = LocalDateTime.now(); } +} diff --git a/src/main/java/com/chaineeproject/chainee/entity/User.java b/src/main/java/com/chaineeproject/chainee/entity/User.java index 4492766..ec6df89 100644 --- a/src/main/java/com/chaineeproject/chainee/entity/User.java +++ b/src/main/java/com/chaineeproject/chainee/entity/User.java @@ -22,15 +22,6 @@ public class User { @Column(nullable = false, unique = true) private String email; - @Column(unique = true, length = 255) - private String did; - - @Column(name = "did_nonce", length = 96) - private String didNonce; - - @Column(name = "did_nonce_expires_at") - private java.time.LocalDateTime didNonceExpiresAt; - private String provider; private String providerId; @@ -40,6 +31,11 @@ public class User { private String kycPhone; private LocalDateTime kycVerifiedAt; + @Column(name = "did_verified", nullable = false) + private boolean didVerified; // 기본 false + @Column(name = "did_verified_at") + private LocalDateTime didVerifiedAt; + @Column(name = "created_at") private LocalDateTime createdAt; @@ -105,6 +101,13 @@ protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } - public void setDidNonce(String n) { this.didNonce = n; } - public String getDidNonce() { return didNonce; } -} + public void markDidVerified() { + this.didVerified = true; + this.didVerifiedAt = LocalDateTime.now(); + } + + public void unmarkDidVerified() { + this.didVerified = false; + this.didVerifiedAt = null; + } +} \ No newline at end of file diff --git a/src/main/java/com/chaineeproject/chainee/repository/ConversationRepository.java b/src/main/java/com/chaineeproject/chainee/repository/ConversationRepository.java new file mode 100644 index 0000000..0cfe768 --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/repository/ConversationRepository.java @@ -0,0 +1,18 @@ +// ConversationRepository.java +package com.chaineeproject.chainee.repository; + +import com.chaineeproject.chainee.entity.Conversation; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ConversationRepository extends JpaRepository { + + // (post.id, applicant.id) 조합으로 단일 대화방 조회 + Optional findByPost_IdAndApplicant_Id(Long postId, Long applicantId); + + // 내가 구인자(공고 작성자)거나 구직자인 대화방 목록 + Page findByPost_Author_IdOrApplicant_Id(Long employerId, Long applicantId, Pageable pageable); +} diff --git a/src/main/java/com/chaineeproject/chainee/repository/JobApplicationRepository.java b/src/main/java/com/chaineeproject/chainee/repository/JobApplicationRepository.java index c2487a6..af0322a 100644 --- a/src/main/java/com/chaineeproject/chainee/repository/JobApplicationRepository.java +++ b/src/main/java/com/chaineeproject/chainee/repository/JobApplicationRepository.java @@ -1,3 +1,4 @@ +// JobApplicationRepository.java package com.chaineeproject.chainee.repository; import com.chaineeproject.chainee.entity.JobApplication; @@ -8,9 +9,35 @@ import java.util.Optional; public interface JobApplicationRepository extends JpaRepository { + + // (기존) 파서 방식 — 필요 없으면 지워도 됨 boolean existsByPostIdAndApplicantId(Long postId, Long applicantId); long countByPostId(Long postId); + // ✅ 언더스코어 없이 JPQL로 직접 정의 + @Query(""" + select (count(a) > 0) + from JobApplication a + where a.post.id = :postId and a.applicant.id = :applicantId + """) + boolean existsForPostAndApplicant(@Param("postId") Long postId, + @Param("applicantId") Long applicantId); + + @Query(""" + select count(a) + from JobApplication a + where a.post.id = :postId + """) + long countByPost(@Param("postId") Long postId); + + @Query(""" + select a + from JobApplication a + where a.post.id = :postId and a.applicant.id = :applicantId + """) + Optional findByPostAndApplicant(@Param("postId") Long postId, + @Param("applicantId") Long applicantId); + @Query(""" select a from JobApplication a @@ -20,4 +47,4 @@ public interface JobApplicationRepository extends JpaRepository findWithPostAndUsersById(@Param("id") Long id); -} \ No newline at end of file +} diff --git a/src/main/java/com/chaineeproject/chainee/repository/MessageRepository.java b/src/main/java/com/chaineeproject/chainee/repository/MessageRepository.java new file mode 100644 index 0000000..bf22f96 --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/repository/MessageRepository.java @@ -0,0 +1,11 @@ +// MessageRepository.java +package com.chaineeproject.chainee.repository; + +import com.chaineeproject.chainee.entity.Message; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MessageRepository extends JpaRepository { + Page findByConversationIdOrderByCreatedAtAsc(Long conversationId, Pageable pageable); +} diff --git a/src/main/java/com/chaineeproject/chainee/repository/UserRepository.java b/src/main/java/com/chaineeproject/chainee/repository/UserRepository.java index 459d52e..d2e0e1e 100644 --- a/src/main/java/com/chaineeproject/chainee/repository/UserRepository.java +++ b/src/main/java/com/chaineeproject/chainee/repository/UserRepository.java @@ -15,27 +15,15 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); boolean existsByEmail(String email); - Optional findByDid(String did); - - // N+1 완화: positions, mainProject만 프리패치 - @EntityGraph(attributePaths = {"positions", "mainProject"}) - Page findAll(Pageable pageable); @EntityGraph(attributePaths = {"positions", "mainProject"}) @Query(""" - select distinct u + select u from User u - left join u.positions p - left join u.myProjects pr where - lower(u.name) like concat('%', lower(:q), '%') - or lower(u.inLocation) like concat('%', lower(:q), '%') - or lower(p) like concat('%', lower(:q), '%') - or ( - pr.isPublic = true - and pr.requiredSkills is not null - and pr.requiredSkills like concat('%', :q, '%') - ) + lower(u.email) like concat('%', lower(:q), '%') + or lower(coalesce(u.provider, '')) like concat('%', lower(:q), '%') + or lower(coalesce(u.providerId, '')) like concat('%', lower(:q), '%') """) Page searchTalents(@Param("q") String q, Pageable pageable); } diff --git a/src/main/java/com/chaineeproject/chainee/security/CustomOauth2UserDetails.java b/src/main/java/com/chaineeproject/chainee/security/CustomOauth2UserDetails.java index 0d8892e..e23efd9 100644 --- a/src/main/java/com/chaineeproject/chainee/security/CustomOauth2UserDetails.java +++ b/src/main/java/com/chaineeproject/chainee/security/CustomOauth2UserDetails.java @@ -69,5 +69,5 @@ public boolean isEnabled() { } public boolean isKycVerified() { return user.isKycVerified(); } - public boolean isDidVerified() { return user.getDid() != null && !user.getDid().isBlank(); } + public boolean isDidVerified() { return user.isDidVerified(); } } diff --git a/src/main/java/com/chaineeproject/chainee/security/CustomOauth2UserService.java b/src/main/java/com/chaineeproject/chainee/security/CustomOauth2UserService.java index 7f5d255..d6da643 100644 --- a/src/main/java/com/chaineeproject/chainee/security/CustomOauth2UserService.java +++ b/src/main/java/com/chaineeproject/chainee/security/CustomOauth2UserService.java @@ -8,9 +8,15 @@ import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.Optional; +/** + * OAuth2 로그인 시 사용자 정보를 적재/동기화하는 서비스. + * - User 엔티티는 did 문자열을 보관하지 않음. (didVerified 플래그만 관리) + * - 신규 회원은 email/provider/providerId만 저장하고, 나머지는 기본값 사용. + */ @Service @RequiredArgsConstructor @Slf4j @@ -19,10 +25,12 @@ public class CustomOauth2UserService extends DefaultOAuth2UserService { private final UserRepository userRepository; @Override + @Transactional public OAuth2User loadUser(OAuth2UserRequest userRequest) { OAuth2User oAuth2User = super.loadUser(userRequest); log.info("OAuth2 user attributes: {}", oAuth2User.getAttributes()); + // Google 기반 예시 (필요 시 provider별 분기) OAuth2UserInfo userInfo = new GoogleUserDetails(oAuth2User.getAttributes()); String email = userInfo.getEmail(); @@ -31,19 +39,31 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) { User user; if (userOptional.isPresent()) { + // 기존 사용자: provider 정보 최신화(필요 시) user = userOptional.get(); + if (user.getProvider() == null) { + user.setProvider(userInfo.getProvider()); + } + if (user.getProviderId() == null) { + user.setProviderId(userInfo.getProviderId()); + } + // (옵션) 이름/프로필 이미지 등 동기화가 필요하면 여기서 업데이트 + // user.setName(userInfo.getName()); + // user.setProfileImageUrl(userInfo.getPicture()); } else { + // 신규 사용자: 기본값과 함께 저장 user = User.builder() .email(userInfo.getEmail()) .provider(userInfo.getProvider()) .providerId(userInfo.getProviderId()) - .kycVerified(false) - .did(null) + .kycVerified(false) // @PrePersist로도 기본 false지만 명시 + .didVerified(false) // @PrePersist로도 기본 false지만 명시 .build(); userRepository.save(user); isNewUser = true; } + // CustomOauth2UserDetails(도메인 User, OAuth attributes, isNewUser) 시그니처 가정 return new CustomOauth2UserDetails(user, oAuth2User.getAttributes(), isNewUser); } } diff --git a/src/main/java/com/chaineeproject/chainee/security/SecurityUtils.java b/src/main/java/com/chaineeproject/chainee/security/SecurityUtils.java index 39c361d..4cc9f27 100644 --- a/src/main/java/com/chaineeproject/chainee/security/SecurityUtils.java +++ b/src/main/java/com/chaineeproject/chainee/security/SecurityUtils.java @@ -11,7 +11,7 @@ public final class SecurityUtils { private SecurityUtils() {} /** - * Jwt → 사용자 ID + * Jwt → 사용자 ID (nullable) * - 우선 uid (숫자) 클레임 사용 * - 없으면 sub를 Long으로 변환 * - 실패 시 null @@ -30,10 +30,26 @@ public static Long uidOrNull(Jwt jwt) { } /** - * (하위호환) Authentication → 사용자 ID - * - principal이 Jwt면 uidOrNull(jwt) - * - OAuth2User면 attributes의 "id" 시도 - * - UserDetails만 있으면(예: username=email) 필요 시 서비스에서 이메일→ID 매핑 + * Jwt → 사용자 ID (non-null 보장, 없으면 IllegalArgumentException) + * 예시 코드에서 사용: STOMP CONNECT, Handshake 등 + */ + public static Long uidFromJwt(Jwt jwt) { + Long uid = uidOrNull(jwt); + if (uid == null) throw new IllegalArgumentException("JWT has no uid/sub claim"); + return uid; + } + + /** + * (옵션) Authentication → 사용자 ID (non-null 보장) + */ + public static Long uidFromAuth(Authentication auth) { + Long uid = extractUserIdOrNull(auth); + if (uid == null) throw new IllegalArgumentException("No authenticated user id"); + return uid; + } + + /** + * (하위호환) Authentication → 사용자 ID (nullable) */ public static Long extractUserIdOrNull(Authentication auth) { if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) { @@ -60,7 +76,6 @@ public static Long extractUserIdOrNull(Authentication auth) { // 3) UserDetails(username=email)만 있는 경우 → 필요 시 이메일→ID 매핑(서비스 단) if (p instanceof UserDetails) { - // 예) return userRepository.findIdByEmail(((UserDetails) p).getUsername()).orElse(null); return null; } diff --git a/src/main/java/com/chaineeproject/chainee/security/ws/HttpHandshakeJwtInterceptor.java b/src/main/java/com/chaineeproject/chainee/security/ws/HttpHandshakeJwtInterceptor.java new file mode 100644 index 0000000..d6bc2de --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/security/ws/HttpHandshakeJwtInterceptor.java @@ -0,0 +1,49 @@ +package com.chaineeproject.chainee.security.ws; + +import com.chaineeproject.chainee.security.SecurityUtils; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import java.util.Arrays; +import java.util.Map; + +/** + * ws://.../ws?token=eyJ... 형태로 접속 시, 세션 attribute에 uid 저장 + * (CONNECT 프레임 Authorization 헤더 사용이 원칙이고, 이건 보조 용도) + */ +public class HttpHandshakeJwtInterceptor implements HandshakeInterceptor { + + // 선택적으로 쿼리파라미터 token 처리 (JwtDecoder는 STOMP 인터셉터에서 사용) + private static JwtDecoder staticDecoder; + + public static void setDecoder(JwtDecoder decoder) { + staticDecoder = decoder; + } + + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Map attributes) { + + String query = request.getURI().getQuery(); + if (query != null && query.contains("token=") && staticDecoder != null) { + String token = Arrays.stream(query.split("&")) + .filter(p -> p.startsWith("token=")) + .map(p -> p.substring("token=".length())) + .findFirst().orElse(null); + + if (token != null && !token.isBlank()) { + Jwt jwt = staticDecoder.decode(token); + Long uid = SecurityUtils.uidFromJwt(jwt); + attributes.put("uid", uid); + } + } + return true; + } + + @Override public void afterHandshake(ServerHttpRequest req, ServerHttpResponse res, + WebSocketHandler wsHandler, Exception ex) {} +} diff --git a/src/main/java/com/chaineeproject/chainee/security/ws/StompJwtChannelInterceptor.java b/src/main/java/com/chaineeproject/chainee/security/ws/StompJwtChannelInterceptor.java new file mode 100644 index 0000000..75539ba --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/security/ws/StompJwtChannelInterceptor.java @@ -0,0 +1,43 @@ +package com.chaineeproject.chainee.security.ws; + +import com.chaineeproject.chainee.security.SecurityUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.*; +import org.springframework.messaging.simp.stomp.*; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class StompJwtChannelInterceptor implements ChannelInterceptor { + + private final JwtDecoder jwtDecoder; + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor acc = StompHeaderAccessor.wrap(message); + + if (StompCommand.CONNECT.equals(acc.getCommand())) { + // 1) Authorization: Bearer xxx or 2) Handshake에서 세팅한 uid 사용 + String auth = acc.getFirstNativeHeader("Authorization"); + if (auth != null && auth.toLowerCase().startsWith("bearer ")) { + String token = auth.substring(7); + Jwt jwt = jwtDecoder.decode(token); + Long uid = SecurityUtils.uidFromJwt(jwt); + acc.setUser(new WsPrincipal(uid)); + } else { + Object uidAttr = acc.getSessionAttributes() != null ? acc.getSessionAttributes().get("uid") : null; + if (uidAttr instanceof Long uid) { + acc.setUser(new WsPrincipal(uid)); + } else { + throw new AccessDeniedException("STOMP_CONNECT_NO_JWT"); + } + } + } + + return message; + } +} diff --git a/src/main/java/com/chaineeproject/chainee/security/ws/WsPrincipal.java b/src/main/java/com/chaineeproject/chainee/security/ws/WsPrincipal.java new file mode 100644 index 0000000..ca2287a --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/security/ws/WsPrincipal.java @@ -0,0 +1,7 @@ +package com.chaineeproject.chainee.security.ws; + +import java.security.Principal; + +public record WsPrincipal(Long userId) implements Principal { + @Override public String getName() { return String.valueOf(userId); } +} diff --git a/src/main/java/com/chaineeproject/chainee/service/ChatService.java b/src/main/java/com/chaineeproject/chainee/service/ChatService.java new file mode 100644 index 0000000..60434e6 --- /dev/null +++ b/src/main/java/com/chaineeproject/chainee/service/ChatService.java @@ -0,0 +1,121 @@ +// ChatService.java +package com.chaineeproject.chainee.service; + +import com.chaineeproject.chainee.entity.Conversation; +import com.chaineeproject.chainee.entity.Message; +import com.chaineeproject.chainee.entity.User; +import com.chaineeproject.chainee.repository.ConversationRepository; +import com.chaineeproject.chainee.repository.JobApplicationRepository; +import com.chaineeproject.chainee.repository.JobPostRepository; +import com.chaineeproject.chainee.repository.MessageRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ChatService { + + private final ConversationRepository conversationRepo; + private final MessageRepository messageRepo; + private final JobApplicationRepository jobAppRepo; + private final JobPostRepository jobPostRepo; + + @PersistenceContext + private EntityManager em; + + @Transactional + public Conversation startConversation(Long postId, Long applicantId, Long requesterId) { + // 중복 생성 방지 + var existing = conversationRepo.findByPost_IdAndApplicant_Id(postId, applicantId); + if (existing.isPresent()) return existing.get(); + + var post = jobPostRepo.findById(postId) + .orElseThrow(() -> new IllegalArgumentException("POST_NOT_FOUND")); + + // 권한: 대화 시작은 공고 작성자만 + if (!post.getAuthor().getId().equals(requesterId)) { + throw new SecurityException("ONLY_EMPLOYER_CAN_START"); + } + + // 지원 여부 검증 + boolean hasApplied = jobAppRepo.existsForPostAndApplicant(postId, applicantId); + if (!hasApplied) throw new IllegalStateException("APPLICANT_NOT_APPLIED"); + + // applicant 프록시 참조(TransientProperty 방지) + var applicantRef = em.getReference(User.class, applicantId); + + var conv = Conversation.builder() + .post(post) + .applicant(applicantRef) + .build(); + + return conversationRepo.save(conv); + } + + @Transactional(readOnly = true) + public Page myConversations(Long userId, int page, int size) { + // 구인자(작성자)거나 구직자인 대화방을 한 번에 조회 + return conversationRepo.findByPost_Author_IdOrApplicant_Id(userId, userId, PageRequest.of(page, size)); + } + + @Transactional + public Message sendMessage(Long conversationId, Long senderId, String content, String attachmentUrl) { + var conv = conversationRepo.findById(conversationId) + .orElseThrow(() -> new IllegalArgumentException("CONV_NOT_FOUND")); + + // 참가자 검증 + boolean isEmployer = conv.getPost().getAuthor().getId().equals(senderId); + boolean isApplicant = conv.getApplicant().getId().equals(senderId); + if (!isEmployer && !isApplicant) throw new SecurityException("NOT_A_PARTICIPANT"); + + var senderRef = em.getReference(User.class, senderId); + + var msg = Message.builder() + .conversation(conv) + .sender(senderRef) + .content(content) + .attachmentUrl(attachmentUrl) + .readByApplicant(isApplicant) // 보낸 사람 쪽은 읽음으로 둘지 여부는 정책에 맞춰 + .readByEmployer(isEmployer) + .build(); + + return messageRepo.save(msg); + } + + @Transactional(readOnly = true) + public Page getMessages(Long conversationId, Long requesterId, int page, int size) { + var conv = conversationRepo.findById(conversationId) + .orElseThrow(() -> new IllegalArgumentException("CONV_NOT_FOUND")); + + if (!conv.getPost().getAuthor().getId().equals(requesterId) && + !conv.getApplicant().getId().equals(requesterId)) { + throw new SecurityException("NOT_A_PARTICIPANT"); + } + + return messageRepo.findByConversationIdOrderByCreatedAtAsc( + conversationId, PageRequest.of(page, size) + ); + } + + @Transactional + public void markRead(Long conversationId, Long requesterId) { + var conv = conversationRepo.findById(conversationId) + .orElseThrow(() -> new IllegalArgumentException("CONV_NOT_FOUND")); + + boolean isEmployer = conv.getPost().getAuthor().getId().equals(requesterId); + boolean isApplicant = conv.getApplicant().getId().equals(requesterId); + if (!isEmployer && !isApplicant) throw new SecurityException("NOT_A_PARTICIPANT"); + + // 간단 버전: 일괄 로드 후 플래그 변경 (규모 커지면 @Modifying bulk-update 권장) + messageRepo.findByConversationIdOrderByCreatedAtAsc(conversationId, PageRequest.of(0, 1000)) + .forEach(m -> { + if (isEmployer) m.setReadByEmployer(true); + if (isApplicant) m.setReadByApplicant(true); + }); + } +} diff --git a/src/main/java/com/chaineeproject/chainee/service/FollowService.java b/src/main/java/com/chaineeproject/chainee/service/FollowService.java index d0c542d..ca55b56 100644 --- a/src/main/java/com/chaineeproject/chainee/service/FollowService.java +++ b/src/main/java/com/chaineeproject/chainee/service/FollowService.java @@ -92,7 +92,6 @@ private UserPreview toPreview(User u) { u.getId(), u.getName(), u.getProfileImageUrl(), - u.getDid(), u.getPositions() ); } diff --git a/src/main/java/com/chaineeproject/chainee/service/JobApplicationService.java b/src/main/java/com/chaineeproject/chainee/service/JobApplicationService.java index 1772ce4..aafd892 100644 --- a/src/main/java/com/chaineeproject/chainee/service/JobApplicationService.java +++ b/src/main/java/com/chaineeproject/chainee/service/JobApplicationService.java @@ -1,3 +1,4 @@ +// src/main/java/com/chaineeproject/chainee/service/JobApplicationService.java package com.chaineeproject.chainee.service; import com.chaineeproject.chainee.dto.JobApplicationRequest; @@ -25,8 +26,8 @@ public class JobApplicationService { @Transactional public void applyToJob(Long postId, JobApplicationRequest request) { - // 1. 지원자 조회 - User applicant = userRepository.findByDid(request.applicantDid()) + // 1. 지원자 조회 (ID) + User applicant = userRepository.findById(request.applicantId()) .orElseThrow(() -> new ApplicationException(ErrorCode.APPLICANT_NOT_FOUND)); // 2. 공고 조회 diff --git a/src/main/java/com/chaineeproject/chainee/service/ResumeService.java b/src/main/java/com/chaineeproject/chainee/service/ResumeService.java index eff278e..a7a6901 100644 --- a/src/main/java/com/chaineeproject/chainee/service/ResumeService.java +++ b/src/main/java/com/chaineeproject/chainee/service/ResumeService.java @@ -1,3 +1,4 @@ +// src/main/java/com/chaineeproject/chainee/service/ResumeService.java package com.chaineeproject.chainee.service; import com.chaineeproject.chainee.dto.*; @@ -28,7 +29,7 @@ public class ResumeService { // ========= 생성 ========= public Long createResume(ResumeCreateRequest request) { - User applicant = userRepository.findByDid(request.applicantDid()) + User applicant = userRepository.findById(request.applicantId()) .orElseThrow(() -> new ApplicationException(ErrorCode.APPLICANT_NOT_FOUND)); Resume resume = new Resume(); diff --git a/src/main/java/com/chaineeproject/chainee/service/UserService.java b/src/main/java/com/chaineeproject/chainee/service/UserService.java index 1aae419..02757cb 100644 --- a/src/main/java/com/chaineeproject/chainee/service/UserService.java +++ b/src/main/java/com/chaineeproject/chainee/service/UserService.java @@ -24,8 +24,4 @@ public User findByEmail(String email) { .orElseThrow(() -> new java.util.NoSuchElementException("해당 이메일의 사용자를 찾을 수 없습니다. email: " + email)); } - public User findByDid(String did) { - return userRepository.findByDid(did) - .orElseThrow(() -> new java.util.NoSuchElementException("해당 DID의 사용자를 찾을 수 없습니다. did: " + did)); - } }