feat: 프로필 사진 — 구글 아바타 동기화 + 사용자 지정 이미지

- 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 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-28 11:39:35 +09:00
co-authored by Claude Opus 4.8
parent 6256d3e63d
commit 98f5f51112
10 changed files with 89 additions and 6 deletions
@@ -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()) {