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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public interface TechStackRepository extends JpaRepository<TechStack, Long> {

List<TechStack> findByNameIn(Collection<String> names);

// 기술 스택 찾기
Optional<TechStack> findFirstByNameOrderByIdAsc(String name);

Optional<TechStack> findFirstByNameIgnoreCaseOrderByIdAsc(String name);
}
60 changes: 30 additions & 30 deletions src/main/java/goorm/ddok/member/service/PlayerProfileService.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import goorm.ddok.member.repository.*;
import goorm.ddok.reputation.domain.UserReputation;
import goorm.ddok.reputation.repository.UserReputationRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand Down Expand Up @@ -41,6 +43,9 @@ public class PlayerProfileService {
private final ChatRoomService chatRoomService;
private final DmRequestCommandService dmRequestService;

@PersistenceContext
private EntityManager em;

/* -------- 포지션 수정 -------- */
public ProfileDto updatePositions(PositionsUpdateRequest req, CustomUserDetails me) {
User user = requireMe(me);
Expand Down Expand Up @@ -171,35 +176,31 @@ public ProfileDto upsertContent(ContentUpdateRequest req, CustomUserDetails me)
public ProfileDto updateTechStacks(TechStacksUpdateRequest req, CustomUserDetails me) {
User meUser = requireMe(me);

// 1) 입력 정규화 + 대소문자 무시 중복 제거 + 공백 정리
List<String> names = (req.getTechStacks() == null) ? List.of()
: req.getTechStacks().stream()
.map(this::trimToNull)
// 0) 입력 그대로 받되, 완전 빈값만 제거 (앞뒤 공백만 트림)
List<String> names = Optional.ofNullable(req.getTechStacks())
.orElse(List.of()).stream()
.map(this::trimToNull) // " " → null
.filter(Objects::nonNull)
.map(s -> s.replaceAll("\\s+", " "))
.collect(Collectors.collectingAndThen(
Collectors.toMap(String::toLowerCase, s -> s, (a, b) -> a, LinkedHashMap::new),
m -> new ArrayList<>(m.values())
));
.toList();

// 2) 기존 UserTechStack 전부 삭제 (조인 테이블 기준)
// 1) 기존 조인 전부 삭제를 DB에 먼저 반영(충돌 방지)
userTechStackRepository.deleteByUserId(meUser.getId());
em.flush(); // <-- 중요: 삭제를 즉시 반영

// 3) 영속 사용자 재조회 (PC 클리어 대비)
// 2) 사용자 재조회 (영속성 컨텍스트 안전하게)
User user = userRepository.findById(meUser.getId())
.orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND));

// 4) 필요한 TechStack 엔티티 확보 (없으면 생성)
// -> UserTechStack만 추가/재생성 (도메인은 UserTechStack 기준)
// 3) 같은 TechStack을 두 번 넣지 않도록 id 레벨에서 차단
LinkedHashSet<Long> seenStackIds = new LinkedHashSet<>();

for (String name : names) {
TechStack stack = techStackRepository.findByName(name)
.orElseGet(() -> techStackRepository.save(TechStack.builder().name(name).build()));

// 이중 추가 방지(이론상 필요 없지만 안전하게)
boolean exists = user.getTechStacks().stream()
.anyMatch(uts -> uts.getTechStack() != null &&
Objects.equals(uts.getTechStack().getId(), stack.getId()));
if (!exists) {
TechStack stack = techStackRepository.findFirstByNameOrderByIdAsc(name)
.orElseGet(() -> techStackRepository.save(
TechStack.builder().name(name).build()
));

if (seenStackIds.add(stack.getId())) { // 같은 id 한 번만 추가
user.getTechStacks().add(
UserTechStack.builder()
.user(user)
Expand All @@ -209,14 +210,13 @@ public ProfileDto updateTechStacks(TechStacksUpdateRequest req, CustomUserDetail
}
}

userRepository.save(user);

// 5) 최신 프로필로 응답
userRepository.saveAndFlush(user); // insert 확정
return buildProfile(user, me);
}


/* -------- 포트폴리오 전체 치환 -------- */
public ProfileDto upsertPortfolio(PortfolioUpdateRequest req, CustomUserDetails me) { // [CHANGED] 구현
public ProfileDto upsertPortfolio(PortfolioUpdateRequest req, CustomUserDetails me) {
User user = requireMe(me);
user = userRepository.findById(user.getId())
.orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND));
Expand All @@ -226,7 +226,7 @@ public ProfileDto upsertPortfolio(PortfolioUpdateRequest req, CustomUserDetails

// (선택) 개수 제한: 20개
if (incoming.size() > 20) {
throw new GlobalException(ErrorCode.PORTFOLIO_TOO_MANY); // [NEW] 에러코드 필요
throw new GlobalException(ErrorCode.PORTFOLIO_TOO_MANY);
}

// 유효성 & 정규화
Expand All @@ -238,14 +238,14 @@ public ProfileDto upsertPortfolio(PortfolioUpdateRequest req, CustomUserDetails
String url = trimToNull(link.getLink());

if (title == null || title.length() > 15) {
throw new GlobalException(ErrorCode.PORTFOLIO_TITLE_INVALID); // [NEW]
throw new GlobalException(ErrorCode.PORTFOLIO_TITLE_INVALID);
}
if (url == null) {
throw new GlobalException(ErrorCode.PORTFOLIO_URL_REQUIRED); // [NEW]
throw new GlobalException(ErrorCode.PORTFOLIO_URL_REQUIRED);
}
// 아주 간단한 URL 체크 (http/https만 허용)
if (!url.matches("(?i)^https?://.+")) {
throw new GlobalException(ErrorCode.PORTFOLIO_URL_INVALID); // [NEW]
throw new GlobalException(ErrorCode.PORTFOLIO_URL_INVALID);
}

normalized.add(UserPortfolio.builder()
Expand All @@ -261,7 +261,7 @@ public ProfileDto upsertPortfolio(PortfolioUpdateRequest req, CustomUserDetails
if (!normalized.isEmpty()) userPortfolioRepository.saveAll(normalized);

// 변경 후 전체 프로필 반환
return buildProfile(user, me); // [CHANGED] 프로필 전체 리턴
return buildProfile(user, me);
}

/* -------- 공개/비공개 토글 -------- */
Expand Down
78 changes: 38 additions & 40 deletions src/main/java/goorm/ddok/player/service/ProfileSearchService.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
import goorm.ddok.reputation.repository.UserReputationRepository;
import jakarta.persistence.criteria.*;
import lombok.RequiredArgsConstructor;
import org.hibernate.query.NullPrecedence;
import org.hibernate.query.criteria.HibernateCriteriaBuilder;
import org.hibernate.query.criteria.JpaOrder;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -41,66 +43,64 @@ public class ProfileSearchService {

@Transactional(readOnly = true)
public Page<ProfileSearchResponse> searchPlayers(String keyword, int page, int size, Long currentUserId) {

// 페이지/사이즈 보정
page = Math.max(page, 0);
size = (size <= 0) ? 10 : size;

// 닉네임 오름차순 (대소문자 무시)
Sort sort = Sort.by(new Sort.Order(Sort.Direction.ASC, "nickname").ignoreCase());
Pageable pageable = PageRequest.of(page, size, sort);
Pageable pageable = PageRequest.of(page, size);

// 기본 스펙: 항상 distinct 적용
Specification<User> spec = (root, query, cb) -> {
Objects.requireNonNull(query).distinct(true);
return cb.conjunction();
};

if (!hasText(keyword)) {
// 키워드 없으면 공개 프로필만
spec = spec.and((root, query, cb) -> cb.isTrue(root.get("isPublic")));
} else {
spec = spec.and(keywordSpec(keyword));
}
Specification<User> spec = Specification
.where(orderByNicknameAscCaseInsensitive())
.and(hasText(keyword) ? keywordSpec(keyword)
: (r, q, cb) -> cb.isTrue(r.get("isPublic")));

Page<User> rows = userRepository.findAll(spec, pageable);

return rows.map(u -> toResponse(u, currentUserId));
}

private Specification<User> orderByNicknameAscCaseInsensitive() {
return (root, query, cb) -> {
Class<?> rt = Objects.requireNonNull(query).getResultType();
if (rt != Long.class && rt != long.class) {
HibernateCriteriaBuilder hcb = (HibernateCriteriaBuilder) cb;

var nickname = root.get("nickname");

JpaOrder byNickname = (JpaOrder) hcb.asc(nickname);
byNickname.nullPrecedence(NullPrecedence.LAST);

var lowerNick = hcb.lower(hcb.coalesce(nickname, "").asString());
JpaOrder byLowerNick = (JpaOrder) hcb.asc(lowerNick);

query.orderBy(byNickname, byLowerNick);
}
return null;
};
}


private Specification<User> keywordSpec(String raw) {
List<String> tokens = splitTokens(raw);

return (root, query, cb) -> {
query.distinct(true); // ★ count 쿼리에도 반영

// 주소는 1:1이라 LEFT JOIN 사용해도 중복 없음

Join<User, UserLocation> locJoin = root.join("location", JoinType.LEFT);

List<Predicate> andPerToken = new ArrayList<>();

for (String token : tokens) {
String like = "%" + token.toLowerCase() + "%";

List<Predicate> ors = new ArrayList<>();

// 1) 닉네임 LIKE (공개 여부와 무관)
ors.add(cb.like(cb.lower(root.get("nickname")), like));

// 2) (isPublic AND EXISTS 포지션 LIKE)
{
Subquery<Long> posSub = query.subquery(Long.class);
Root<UserPosition> p = posSub.from(UserPosition.class);
posSub.select(cb.literal(1L));
Predicate link = cb.equal(p.get("user").get("id"), root.get("id"));
Predicate posLike = cb.like(cb.lower(p.get("positionName")), like);
posSub.where(cb.and(link, posLike));
Predicate posExists = cb.exists(posSub);
Subquery<Long> posSub = Objects.requireNonNull(query).subquery(Long.class);
Root<UserPosition> p = posSub.from(UserPosition.class);
posSub.select(cb.literal(1L));
posSub.where(
cb.equal(p.get("user").get("id"), root.get("id")),
cb.like(cb.lower(p.get("positionName")), like)
);
ors.add(cb.and(cb.isTrue(root.get("isPublic")), cb.exists(posSub)));

ors.add(cb.and(cb.isTrue(root.get("isPublic")), posExists));
}

// 3) (isPublic AND 주소 필드 LIKE)
ors.add(cb.and(cb.isTrue(root.get("isPublic")),
cb.like(cb.lower(cb.coalesce(locJoin.get("region1DepthName"), "")), like)));
ors.add(cb.and(cb.isTrue(root.get("isPublic")),
Expand All @@ -114,7 +114,6 @@ private Specification<User> keywordSpec(String raw) {
ors.add(cb.and(cb.isTrue(root.get("isPublic")),
cb.like(cb.lower(cb.coalesce(locJoin.get("subBuildingNo"), "")), like)));

// "서울 강남" 같은 합성 주소 매칭
ors.add(cb.and(cb.isTrue(root.get("isPublic")),
cb.like(
cb.lower(
Expand All @@ -128,7 +127,6 @@ private Specification<User> keywordSpec(String raw) {

andPerToken.add(cb.or(ors.toArray(new Predicate[0])));
}

return cb.and(andPerToken.toArray(new Predicate[0]));
};
}
Expand Down