diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 45fb316..c175044 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -38,6 +38,11 @@ jobs: run: chmod +x gradlew - name: Gradle 빌드 수행 + env: + KAKAO_CLIENT_ID: ${{ secrets.KAKAO_CLIENT_ID }} + MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }} + MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }} + SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} run: ./gradlew clean assemble --info - name: 빌드 아티팩트 업로드 @@ -106,7 +111,7 @@ jobs: echo "Generating .env.properties file..." # 1. Secrets 변수 주입 - echo "jwt.secret.key='${{ secrets.JWT_SECRET_KEY }}'" >> .env.properties + echo "jwt.secret.key='${{ secrets.SECRET_KEY }}'" >> .env.properties echo "kakao.client.id=${{ secrets.KAKAO_CLIENT_ID }}" >> .env.properties echo "mail.password='${{ secrets.MAIL_PASSWORD }}'" >> .env.properties echo "mail.username=${{ secrets.MAIL_USERNAME }}" >> .env.properties @@ -119,9 +124,16 @@ jobs: SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/pinco SPRING_DATASOURCE_USERNAME: user SPRING_DATASOURCE_PASSWORD: password - # Redis(Spring Boot 기본 설정 사용 시) + SPRING_DATA_REDIS_HOST: localhost SPRING_DATA_REDIS_PORT: 6379 + + # 환경 변수 전달 (메일/카카오/시크릿 등) + KAKAO_CLIENT_ID: ${{ secrets.KAKAO_CLIENT_ID }} + MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }} + MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }} + SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} + run: ./gradlew test --info - name: 테스트 결과를 PR에 코멘트로 등록 diff --git a/front/src/app/user/join/page.tsx b/front/src/app/user/join/page.tsx index 31a165d..9f506c1 100644 --- a/front/src/app/user/join/page.tsx +++ b/front/src/app/user/join/page.tsx @@ -2,19 +2,59 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { Mail, Lock, User } from "lucide-react"; -import { apiJoin } from "@/lib/pincoApi"; +import { Mail, Lock, User, Key } from "lucide-react"; +import { apiJoin, apiSendVerificationCode } from "@/lib/pincoApi"; export default function SignUpPage() { const router = useRouter(); - const [form, setForm] = useState({ userName: "", email: "", password: "" }); + const [form, setForm] = useState({ userName: "", email: "", password: "", verificationCode: "" }); const [loading, setLoading] = useState(false); + const [sendingCode, setSendingCode] = useState(false); + const [codeSent, setCodeSent] = useState(false); const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setForm(prev => ({ ...prev, [name]: value })); }; + const handlePasswordBlur = (e: React.FocusEvent) => { + const password = e.target.value.trim(); + if (password && password.length < 8) { + alert("비밀번호는 8자 이상 입력해야 합니다."); + } + }; + + const handleSendVerificationCode = async () => { + const email = form.email.trim().toLowerCase(); + + if (!email) { + alert("이메일을 먼저 입력해주세요."); + return; + } + + // 이메일 형식 간단 검증 + if (!email.includes("@") || !email.includes(".")) { + alert("올바른 이메일 형식을 입력해주세요."); + return; + } + + setSendingCode(true); + try { + await apiSendVerificationCode(email); + alert("인증코드가 발송되었습니다. 이메일을 확인해주세요."); + setCodeSent(true); + } catch (err: any) { + const msg = err?.message ?? ""; + if (msg.includes("이메일 형식")) { + alert("이메일 형식이 올바르지 않습니다."); + } else { + alert(msg || "인증코드 발송에 실패했습니다."); + } + } finally { + setSendingCode(false); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (loading) return; @@ -23,6 +63,7 @@ export default function SignUpPage() { const email = form.email.trim().toLowerCase(); const password = form.password.trim(); const userName = form.userName.trim(); + const verificationCode = form.verificationCode.trim(); // ⚙️ 프론트 최소 유효성 if (password.length < 8) { @@ -31,9 +72,15 @@ export default function SignUpPage() { return; } + if (!verificationCode) { + alert("인증코드를 입력해주세요."); + setLoading(false); + return; + } + try { // ⚙️ 서버 요청 - const res: any = await apiJoin(email, password, userName); + const res: any = await apiJoin(email, password, userName, verificationCode); const code = res?.resultCode ?? res?.errorCode ?? "200"; const msg = res?.msg ?? ""; @@ -78,6 +125,8 @@ export default function SignUpPage() { alert("이미 가입된 이메일입니다. 로그인해주세요."); else if (raw.includes("이미 사용 중인 회원이름")) alert("이미 사용 중인 회원 이름입니다. 다른 이름을 입력해주세요."); + else if (raw.includes("인증 코드") || raw.includes("인증코드")) + alert(raw || "인증코드가 일치하지 않거나 만료되었습니다."); else alert(raw || "회원가입 중 오류가 발생했습니다 ❌"); } finally { setLoading(false); @@ -111,17 +160,49 @@ export default function SignUpPage() { {/* 이메일 */}
- +
+ + +
+ {/* 인증코드 */} + {codeSent && ( +
+ + +
+ )} + {/* 비밀번호 */}
@@ -130,6 +211,7 @@ export default function SignUpPage() { name="password" value={form.password} onChange={handleChange} + onBlur={handlePasswordBlur} placeholder="비밀번호 (8자 이상)" minLength={8} required diff --git a/front/src/app/user/mypage/edit/page.tsx b/front/src/app/user/mypage/edit/page.tsx index 7308061..daca0f9 100644 --- a/front/src/app/user/mypage/edit/page.tsx +++ b/front/src/app/user/mypage/edit/page.tsx @@ -44,8 +44,8 @@ export default function EditMyInfoPage() { return; } - // ✅ 서버에 보낼 payload 구성 - const payload: Record = { password }; + // ✅ 서버에 보낼 payload 구성 (newUserName, newPassword는 선택적 - 값이 있을 때만 전송) + const payload: Record = { password }; if (newUserName.trim()) payload.newUserName = newUserName.trim(); if (newPassword) payload.newPassword = newPassword; diff --git a/front/src/hooks/usePins.ts b/front/src/hooks/usePins.ts index b50a8fe..8c13613 100644 --- a/front/src/hooks/usePins.ts +++ b/front/src/hooks/usePins.ts @@ -267,33 +267,13 @@ export function usePins(initialCenter: UsePinsProps, userId?: number | null) { return; } - const apiKey = localStorage.getItem("apiKey"); - const accessToken = localStorage.getItem("accessToken"); - - if (!apiKey || !accessToken) { - console.error("❌ 토큰이 없습니다. 로그인이 필요합니다."); - alert("로그인이 필요합니다."); - return; - } - setLoading(true); try { - const res = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/bookmarks`, { + const data = await fetchApi("/api/bookmarks", { method: "GET", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${apiKey} ${accessToken}`, - }, - credentials: "include", }); - if (!res.ok) { - throw new Error(`HTTP error! status: ${res.status}`); - } - - const data = await res.json(); - - const pinsOnly = extractArray(data.data).map((b: any) => b.pin ?? b); + const pinsOnly = extractArray(data).map((b: any) => b.pin ?? b); const normalized = normalizePins(pinsOnly); const pinsWithTags = await loadTagsForPins(normalized); @@ -324,36 +304,13 @@ export function usePins(initialCenter: UsePinsProps, userId?: number | null) { return; } - const apiKey = localStorage.getItem("apiKey"); - const accessToken = localStorage.getItem("accessToken"); - - if (!apiKey || !accessToken) { - console.error("❌ 토큰이 없습니다. 로그인이 필요합니다."); - alert("로그인이 필요합니다."); - return; - } - setLoading(true); try { - const res = await fetch( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/user/${userId}/likespins`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${apiKey} ${accessToken}`, - }, - credentials: "include", - } - ); - - if (!res.ok) { - throw new Error(`HTTP error! status: ${res.status}`); - } - - const data = await res.json(); + const data = await fetchApi(`/api/user/${userId}/likespins`, { + method: "GET", + }); - const likedArray = extractArray(data.data); + const likedArray = extractArray(data); const normalized = normalizePins(likedArray); const pinsWithTags = await loadTagsForPins(normalized); diff --git a/front/src/lib/pincoApi.ts b/front/src/lib/pincoApi.ts index bc25cfa..fc3d9ca 100644 --- a/front/src/lib/pincoApi.ts +++ b/front/src/lib/pincoApi.ts @@ -118,33 +118,23 @@ export const apiDeletePin = (id: number) => // ✅ 좋아요 추가 export const apiAddLike = async (pinId: number, userId: number) => { - const apiKey = localStorage.getItem("apiKey"); - const accessToken = localStorage.getItem("accessToken"); - - if (!apiKey || !accessToken) { - console.error("❌ 토큰이 없습니다. 로그인이 필요합니다."); - alert("로그인이 필요합니다."); - return; - } const res:LikesStatusDto = await fetchApi(`/api/pins/${pinId}/likes`, { method: "POST", - headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey} ${accessToken}` }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId }), }); - if (res) return res; // ✅ { data: { isLiked, likeCount } } + if (res) return res; // ✅ { data: { isLiked, likeCount } } }; // ✅ 좋아요 취소 export const apiRemoveLike = async (pinId: number, userId: number) => { - const apiKey = localStorage.getItem("apiKey"); - const accessToken = localStorage.getItem("accessToken"); const res:LikesStatusDto = await fetchApi(`/api/pins/${pinId}/likes`, { method: "DELETE", - headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey} ${accessToken}` }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId }), }); - if (res) return res; // ✅ { data: { isLiked, likeCount } } + if (res) return res; // ✅ { data: { isLiked, likeCount } } }; export const apiGetLikeUsers = (pinId: number) => @@ -174,11 +164,18 @@ export const apiDeleteBookmark = (bookmarkId: number) => { }; // ---------- User ---------- -export const apiJoin = (email: string, password: string, userName: string) => +export const apiSendVerificationCode = (email: string) => + fetchApi(`/api/user/send-verification-code`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + +export const apiJoin = (email: string, password: string, userName: string, verificationCode: string) => fetchApi(`/api/user/join`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password, userName }), + body: JSON.stringify({ email, password, userName, verificationCode }), }); export const apiDeleteAccount = (password: string) => diff --git a/src/main/java/com/back/pinco/domain/tag/entity/PinTag.kt b/src/main/java/com/back/pinco/domain/tag/entity/PinTag.kt index e12d32a..e230c46 100644 --- a/src/main/java/com/back/pinco/domain/tag/entity/PinTag.kt +++ b/src/main/java/com/back/pinco/domain/tag/entity/PinTag.kt @@ -16,7 +16,7 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener indexes = [ Index(name = "idx_pin_tag_pin", columnList = "pin_id"), Index(name = "idx_pin_tag_tag",columnList = "tag_id"), - Index(name = "idx_pin_tag_deleted", columnList = "is_deleted") + // Index(name = "idx_pin_tag_deleted", columnList = "is_deleted") ] ) @EntityListeners(AuditingEntityListener::class) diff --git a/src/main/java/com/back/pinco/domain/user/controller/UserController.kt b/src/main/java/com/back/pinco/domain/user/controller/UserController.kt index 0530987..bdc2d9d 100644 --- a/src/main/java/com/back/pinco/domain/user/controller/UserController.kt +++ b/src/main/java/com/back/pinco/domain/user/controller/UserController.kt @@ -34,7 +34,6 @@ class UserController( private val rq: Rq ) { - @Operation(summary = "인증코드 발송", description = "이메일로 6자리 인증코드를 발송합니다.") @PostMapping("/send-verification-code") fun sendVerificationCode( diff --git a/src/main/java/com/back/pinco/domain/user/dto/UserReqBody/EditRequest.kt b/src/main/java/com/back/pinco/domain/user/dto/UserReqBody/EditRequest.kt index e301169..15508fb 100644 --- a/src/main/java/com/back/pinco/domain/user/dto/UserReqBody/EditRequest.kt +++ b/src/main/java/com/back/pinco/domain/user/dto/UserReqBody/EditRequest.kt @@ -2,6 +2,6 @@ package com.back.pinco.domain.user.dto.UserReqBody data class EditRequest( val password: String, // 현재 비밀번호 (검증용) - val newUserName: String, // 변경할 닉네임 - val newPassword: String // 변경할 비밀번호 + val newUserName: String? = null, // 변경할 닉네임 (선택) + val newPassword: String? = null // 변경할 비밀번호 (선택) ) diff --git a/src/main/java/com/back/pinco/domain/user/service/MailService.kt b/src/main/java/com/back/pinco/domain/user/service/MailService.kt index 5804361..559c8fe 100644 --- a/src/main/java/com/back/pinco/domain/user/service/MailService.kt +++ b/src/main/java/com/back/pinco/domain/user/service/MailService.kt @@ -1,5 +1,6 @@ package com.back.pinco.domain.user.service +import org.springframework.beans.factory.annotation.Value import org.springframework.mail.javamail.JavaMailSender import org.springframework.mail.SimpleMailMessage import org.springframework.stereotype.Service @@ -7,7 +8,8 @@ import kotlin.random.Random @Service class MailService( - private val mailSender: JavaMailSender + private val mailSender: JavaMailSender, + @Value("\${spring.mail.username}") private val fromAddress: String ) { fun generateVerificationCode(): String { return (100000..999999).random().toString() @@ -26,6 +28,7 @@ class MailService( text: String ) { val message = SimpleMailMessage().apply { + setFrom(fromAddress) setTo(to) this.subject = subject this.text = text diff --git a/src/main/java/com/back/pinco/domain/user/service/UserService.kt b/src/main/java/com/back/pinco/domain/user/service/UserService.kt index bc77c6c..2026734 100644 --- a/src/main/java/com/back/pinco/domain/user/service/UserService.kt +++ b/src/main/java/com/back/pinco/domain/user/service/UserService.kt @@ -194,24 +194,28 @@ class UserService( fun findById(id: Long): User = userRepository.findById(id) .orElseThrow { ServiceException(ErrorCode.USER_NOT_FOUND) } - private fun nameChanged(currentUser: User, newUserName: String): Boolean = - newUserName.trim().isNotBlank() && newUserName.trim() != currentUser.userName + private fun nameChanged(currentUser: User, newUserName: String?): Boolean { + val trimmed = newUserName?.trim() ?: return false + return trimmed.isNotBlank() && trimmed != currentUser.userName + } - private fun passwordChanged(currentUser: User, newPassword: String): Boolean = - newPassword.isNotBlank() && !passwordEncoder.matches(newPassword, currentUser.password) + private fun passwordChanged(currentUser: User, newPassword: String?): Boolean { + val trimmed = newPassword?.trim() ?: return false + return trimmed.isNotBlank() && !passwordEncoder.matches(trimmed, currentUser.password) + } @Transactional - fun editUserInfo(userId: Long, newUserName: String, newPassword: String) { + fun editUserInfo(userId: Long, newUserName: String?, newPassword: String?) { val currentUser = userRepository.findById(userId) .orElseThrow { ServiceException(ErrorCode.USER_NOT_FOUND) } val nameChanged = nameChanged(currentUser, newUserName) val pwdChanged = passwordChanged(currentUser, newPassword) when { - nameChanged && pwdChanged -> editAll(currentUser, newUserName, newPassword) - nameChanged -> editName(currentUser, newUserName) - pwdChanged -> editPwd(currentUser, newPassword) + nameChanged && pwdChanged -> editAll(currentUser, newUserName ?: "", newPassword ?: "") + nameChanged -> editName(currentUser, newUserName ?: "") + pwdChanged -> editPwd(currentUser, newPassword ?: "") else -> throw ServiceException(ErrorCode.NO_FIELDS_TO_UPDATE) } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9e90b1b..2df5bc9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -65,6 +65,6 @@ logging: org.springframework.web: DEBUG custom: jwt: - secret: "aVeryLongSecretKey_ChangeMe_2025!" + secret: ${SECRET_KEY} accessExpireSeconds: 1800 refreshExpireSeconds: 86400 \ No newline at end of file