From 98f5f511126e664d24fda4a0148031e03bb763ac Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sun, 28 Jun 2026 11:39:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=82=AC?= =?UTF-8?q?=EC=A7=84=20=E2=80=94=20=EA=B5=AC=EA=B8=80=20=EC=95=84=EB=B0=94?= =?UTF-8?q?=ED=83=80=20=EB=8F=99=EA=B8=B0=ED=99=94=20+=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=A7=80=EC=A0=95=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - member.google_picture(구글 아바타 URL), profile_image(사용자 지정 data URL) 컬럼 추가(멱등) - 구글 로그인 시 picture 클레임 추출 → 신규 저장, 기존은 변경 시 동기화 - PUT /api/auth/profile-image: 사용자 지정 사진 설정/해제(빈값=해제→구글 폴백), data URL·용량 검증 - MemberResponse 에 googlePicture/profileImage 노출(표시 우선순위 프론트 처리) - 관리자 목록(findAll)은 무거운 profile_image 제외해 가볍게 조회 Co-Authored-By: Claude Opus 4.8 --- .../web/auth/controller/AuthController.java | 8 +++++ .../java/com/sb/web/auth/domain/Member.java | 2 ++ .../com/sb/web/auth/dto/MemberResponse.java | 4 +++ .../sb/web/auth/dto/ProfileImageRequest.java | 13 ++++++++ .../com/sb/web/auth/mapper/MemberMapper.java | 6 ++++ .../com/sb/web/auth/service/AuthService.java | 32 +++++++++++++++++++ .../com/sb/web/common/config/WebConfig.java | 2 +- src/main/resources/db/member.sql | 5 +++ src/main/resources/mapper/MemberMapper.xml | 20 +++++++++--- .../sb/web/common/config/WebConfigTest.java | 3 +- 10 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/sb/web/auth/dto/ProfileImageRequest.java diff --git a/src/main/java/com/sb/web/auth/controller/AuthController.java b/src/main/java/com/sb/web/auth/controller/AuthController.java index c0898fc..41f37b6 100644 --- a/src/main/java/com/sb/web/auth/controller/AuthController.java +++ b/src/main/java/com/sb/web/auth/controller/AuthController.java @@ -104,4 +104,12 @@ public class AuthController { HttpServletRequest request) { return authService.updateProfile(current.getId(), AuthInterceptor.resolveToken(request), req); } + + /** 프로필 사진(사용자 지정) 변경/해제 — image 가 비면 해제(구글 사진으로 폴백) */ + @PutMapping("/profile-image") + public MemberResponse updateProfileImage( + @RequestBody com.sb.web.auth.dto.ProfileImageRequest req, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return authService.updateProfileImage(current.getId(), req.getImage()); + } } diff --git a/src/main/java/com/sb/web/auth/domain/Member.java b/src/main/java/com/sb/web/auth/domain/Member.java index eec71c1..4f5d261 100644 --- a/src/main/java/com/sb/web/auth/domain/Member.java +++ b/src/main/java/com/sb/web/auth/domain/Member.java @@ -26,6 +26,8 @@ public class Member implements Serializable { private String provider; // LOCAL / NAVER / GOOGLE private String providerId; // 소셜 제공자 측 고유 ID private String googleId; // 구글 sub (계정 연결용 — LOCAL 계정에 구글을 연결해도 provider 는 유지) + private String googlePicture; // 구글 아바타 URL (로그인 시 동기화) + private String profileImage; // 사용자 지정 프로필 이미지(data URL). 우선순위: profileImage > googlePicture > 이니셜 private String role; // USER / ADMIN private String plan; // FREE / PREMIUM (멤버십) private String status; // ACTIVE / SUSPENDED / WITHDRAWN diff --git a/src/main/java/com/sb/web/auth/dto/MemberResponse.java b/src/main/java/com/sb/web/auth/dto/MemberResponse.java index a19e3d9..ae5c480 100644 --- a/src/main/java/com/sb/web/auth/dto/MemberResponse.java +++ b/src/main/java/com/sb/web/auth/dto/MemberResponse.java @@ -18,6 +18,8 @@ public class MemberResponse { private String provider; private String role; private String plan; // FREE / PREMIUM + private String googlePicture; // 구글 아바타 URL + private String profileImage; // 사용자 지정 프로필 이미지(data URL) public static MemberResponse from(Member m) { return MemberResponse.builder() @@ -28,6 +30,8 @@ public class MemberResponse { .provider(m.getProvider()) .role(m.getRole()) .plan(m.getPlan()) + .googlePicture(m.getGooglePicture()) + .profileImage(m.getProfileImage()) .build(); } } diff --git a/src/main/java/com/sb/web/auth/dto/ProfileImageRequest.java b/src/main/java/com/sb/web/auth/dto/ProfileImageRequest.java new file mode 100644 index 0000000..c151f06 --- /dev/null +++ b/src/main/java/com/sb/web/auth/dto/ProfileImageRequest.java @@ -0,0 +1,13 @@ +package com.sb.web.auth.dto; + +import lombok.Data; + +/** + * 프로필 사진(사용자 지정) 변경 요청. image 가 null/빈 값이면 해제(구글 사진으로 폴백). + */ +@Data +public class ProfileImageRequest { + + /** data URL(base64 이미지). null 이면 사용자 지정 사진 해제. */ + private String image; +} diff --git a/src/main/java/com/sb/web/auth/mapper/MemberMapper.java b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java index e497ca8..74d3705 100644 --- a/src/main/java/com/sb/web/auth/mapper/MemberMapper.java +++ b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java @@ -40,6 +40,12 @@ public interface MemberMapper { /** 프로필(이름/이메일) 변경 */ int updateProfile(@Param("id") Long id, @Param("name") String name, @Param("email") String email); + /** 구글 로그인 시 구글 아바타 URL 동기화 */ + int updateGooglePicture(@Param("id") Long id, @Param("googlePicture") String googlePicture); + + /** 사용자 지정 프로필 이미지 설정/해제(null = 해제 → 구글 사진으로 폴백) */ + int updateProfileImage(@Param("id") Long id, @Param("profileImage") String profileImage); + int updateRole(@Param("id") Long id, @Param("role") String role); int updatePlan(@Param("id") Long id, @Param("plan") String plan); diff --git a/src/main/java/com/sb/web/auth/service/AuthService.java b/src/main/java/com/sb/web/auth/service/AuthService.java index 2bc7b6e..49ac6f0 100644 --- a/src/main/java/com/sb/web/auth/service/AuthService.java +++ b/src/main/java/com/sb/web/auth/service/AuthService.java @@ -186,6 +186,7 @@ public class AuthService { boolean emailVerified = "true".equals(String.valueOf(info.get("email_verified"))); String name = info.get("name") != null ? String.valueOf(info.get("name")) : (email != null ? email.split("@")[0] : "사용자"); + String picture = info.get("picture") != null ? String.valueOf(info.get("picture")) : null; // 1) 구글 sub 로 이미 연결/가입된 계정 조회 Member member = memberMapper.findByGoogleId(sub); @@ -216,6 +217,7 @@ public class AuthService { .provider("GOOGLE") .providerId(sub) .googleId(sub) + .googlePicture(picture) .role("USER") .status("ACTIVE") .build(); @@ -225,6 +227,11 @@ public class AuthService { if (!"ACTIVE".equals(member.getStatus())) { throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다."); } + // 구글 아바타가 바뀌었으면 동기화(신규 가입은 이미 반영됨 → 변경 없을 때 불필요한 UPDATE 생략) + if (picture != null && !picture.equals(member.getGooglePicture())) { + memberMapper.updateGooglePicture(member.getId(), picture); + member.setGooglePicture(picture); + } return issueSession(member, rememberMe); } @@ -335,6 +342,31 @@ public class AuthService { return MemberResponse.from(member); } + /** + * 프로필 사진(사용자 지정) 설정/해제. null/빈 값이면 해제 → 구글 사진으로 폴백. + * data URL(base64 이미지)만 허용하고 과도한 크기는 거절한다. + */ + @Transactional + public MemberResponse updateProfileImage(Long memberId, String image) { + Member member = memberMapper.findById(memberId); + if (member == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다."); + } + String value = (image == null || image.isBlank()) ? null : image.trim(); + if (value != null) { + if (!value.startsWith("data:image/")) { + throw new ApiException(HttpStatus.BAD_REQUEST, "이미지 형식이 올바르지 않습니다."); + } + if (value.length() > 700_000) { // 약 500KB 이미지 상한 (base64 약 33% 팽창 고려) + throw new ApiException(HttpStatus.BAD_REQUEST, "이미지 용량이 너무 큽니다. 더 작은 사진을 사용하세요."); + } + } + memberMapper.updateProfileImage(memberId, value); + member.setProfileImage(value); + log.info("[profile] image {} for {}", value == null ? "cleared" : "updated", member.getLoginId()); + return MemberResponse.from(member); + } + /** 프로필 이름 변경 시 활성 세션(Redis + DB 백업)의 표시 이름을 맞춘다. */ private void syncSessionName(String token, String name) { if (token == null || token.isBlank()) { diff --git a/src/main/java/com/sb/web/common/config/WebConfig.java b/src/main/java/com/sb/web/common/config/WebConfig.java index efe5084..2d13145 100644 --- a/src/main/java/com/sb/web/common/config/WebConfig.java +++ b/src/main/java/com/sb/web/common/config/WebConfig.java @@ -26,7 +26,7 @@ public class WebConfig implements WebMvcConfigurer { // 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행) registry.addInterceptor(authInterceptor) .addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/auth/password", - "/api/auth/verify-password", "/api/auth/profile", + "/api/auth/verify-password", "/api/auth/profile", "/api/auth/profile-image", "/api/board/**", "/api/admin/**", "/api/account/**"); // 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사) registry.addInterceptor(adminInterceptor) diff --git a/src/main/resources/db/member.sql b/src/main/resources/db/member.sql index c2d1646..167b518 100644 --- a/src/main/resources/db/member.sql +++ b/src/main/resources/db/member.sql @@ -30,6 +30,11 @@ UPDATE member SET google_id = provider_id WHERE provider = 'GOOGLE' AND google_i -- 멤버십 플랜 (무료/유료). 기존 회원은 FREE 로 시작 (멱등). ALTER TABLE member ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NOT NULL DEFAULT 'FREE' COMMENT 'FREE / PREMIUM' AFTER role; +-- 프로필 사진. google_picture 는 구글 로그인 시 동기화되는 구글 아바타 URL, +-- profile_image 는 사용자가 직접 올린 커스텀 이미지(data URL). 표시 우선순위: profile_image > google_picture > 이니셜. +ALTER TABLE member ADD COLUMN IF NOT EXISTS google_picture VARCHAR(500) NULL COMMENT '구글 아바타 URL (로그인 시 동기화)' AFTER google_id; +ALTER TABLE member ADD COLUMN IF NOT EXISTS profile_image MEDIUMTEXT NULL COMMENT '사용자 지정 프로필 이미지(data URL)' AFTER google_picture; + -- ============================================================ -- 앱 전역 설정 (단일 행, id=1). 관리자 화면에서 변경. -- ============================================================ diff --git a/src/main/resources/mapper/MemberMapper.xml b/src/main/resources/mapper/MemberMapper.xml index 3506bf8..0e95300 100644 --- a/src/main/resources/mapper/MemberMapper.xml +++ b/src/main/resources/mapper/MemberMapper.xml @@ -12,6 +12,8 @@ + + @@ -20,15 +22,17 @@ - id, login_id, password, name, email, provider, provider_id, google_id, role, plan, status, created_at, updated_at + id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, status, created_at, updated_at +