diff --git a/.gitignore b/.gitignore index f645e52..c0408f7 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,5 @@ out/ ### VS Code ### .vscode/ -.env* \ No newline at end of file +.env* +kafka.server.truststore.jks \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef5ec87 --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ +# Loopang User Service + +loopang MSA 프로젝트의 유저 + 배송담당자 도메인 서비스. + +## 기술 스택 + +- Java 21, Spring Boot 3.5.13, Spring Cloud 2025.0.1 +- Spring MVC (Servlet 기반) +- 공통 모듈: `com.loopang:common:0.0.4-SNAPSHOT` +- PostgreSQL 17, JPA + QueryDSL +- Keycloak 24.0.0 (OAuth2 Resource Server, Admin Client) +- Redis (세션/캐싱) +- Eureka Client + Config Server +- Lombok, Gradle +- server.port: Config Server에서 관리 + +## 도메인 범위 + +- **유저 관리** (회원가입, 로그인/로그아웃, 조회, 수정, 삭제) — `p_user` +- **배송담당자 관리** — `p_courier` +- **인증** — Keycloak 연동 (토큰 발급, 회원가입, 탈퇴) + +## API 엔드포인트 + +### 인증 + +| 메서드 | URL | 설명 | 인증 | 권한 | 상태 | +|---|---|---|---|---|---| +| POST | `/api/users` | 회원가입 (Keycloak + DB) | 불필요 | 전체 (MASTER 제외) | ✅ | +| POST | `/api/users/login` | 로그인 (JWT 토큰 발급) | 불필요 | 전체 | ✅ | +| POST | `/api/users/logout` | 로그아웃 (토큰 무효화) | 불필요 | 전체 | ✅ | + +### 유저 + +| 메서드 | URL | 설명 | 인증 | 권한 | 상태 | +|---|---|---|---|---|---| +| GET | `/api/users/me` | 현재 유저 정보 | X-User-UUID | 본인 | ✅ | +| GET | `/api/users/{userId}` | 유저 단건 조회 | X-User-Role | MASTER | ✅ | +| GET | `/api/users` | 유저 목록 조회 (페이징) | X-User-Role | MASTER | ✅ | +| PATCH | `/api/users/{userId}` | 유저 수정 | X-User-Role | MASTER | ✅ | +| DELETE | `/api/users/{userId}` | 유저 삭제 (소프트) | X-User-UUID + X-User-Role | MASTER | ✅ | + +### 배송담당자 (구현 예정) + +| 메서드 | URL | 설명 | 인증 | 상태 | +|---|---|---|---|---| +| GET | `/api/couriers` | 배송담당자 목록 | 필요 | ⬜ | +| PUT | `/api/couriers/{courierId}` | 배송순번 변경 | MASTER/HUB | ⬜ | + +## 보안 + +### 권한 체크 +- MASTER 전용 API: Controller에서 `@RequestHeader("X-User-Role")` + `checkMaster()` 검증 +- 회원가입 시 MASTER 권한 직접 지정 차단 (`ForbiddenException`) +- 회원가입 후 `approved=false` → 관리자 승인 전까지 서비스 사용 제한 + +### 보상 트랜잭션 +- 회원가입: Keycloak 등록 성공 후 DB 저장 실패 시 → Keycloak 유저 롤백 (`identityProvider.withdraw`) +- 롤백 실패 시 원인 예외 보존 (별도 try-catch) + +### 에러 메시지 +- 이메일/SlackID 중복 에러에 원문 미노출 (개인정보 보호) + +## 테이블 구조 + +### p_user (유저) + +| 컬럼 | 타입 | 제약 | 설명 | +|---|---|---|---| +| id | UUID | PK | 유저ID (Keycloak sub) | +| email | VARCHAR(50) | NOT NULL | 이메일 | +| name | VARCHAR(50) | NOT NULL | 이름 | +| slack_id | VARCHAR(100) | NOT NULL | 슬랙ID | +| role | VARCHAR(10) | NOT NULL | 권한 (MASTER/HUB/DELIVERY/COMPANY/PENDING) | +| hub_id | UUID | NOT NULL | 소속허브ID | +| hub_name | VARCHAR(50) | NOT NULL | 소속허브명 | +| company_id | UUID | NULL | 업체ID (업체 관련 사용자만) | +| company_name | VARCHAR(100) | NULL | 업체명 | +| approved | BOOLEAN | | 승인 여부 | +| version | INT | | 낙관적 락 (@Version) | +| + BaseUserEntity (createdAt/By, updatedAt/By, deletedAt/By) | + +### p_courier (배송담당자) + +| 컬럼 | 타입 | 제약 | 설명 | +|---|---|---|---| +| courier_id | UUID | PK | 배송담당자ID | +| user_id | UUID | FK, NOT NULL | 유저ID | +| delivery_charge_type | VARCHAR(10) | NOT NULL | HUB / COMPANY | +| delivery_turn | INT | NOT NULL | 배송순번 | +| version | INT | | 낙관적 락 (@Version) | +| + BaseUserEntity | + +### 엔티티 특징 +- `@SQLRestriction("deleted_at IS NULL")` — 소프트 삭제된 데이터 조회 시 자동 제외 +- `@Version` — 낙관적 락 (동시 수정 방지) +- `BaseUserEntity` 상속 — createdAt/By, updatedAt/By, deletedAt/By 자동 관리 +- `HubInfo`, `CompanyInfo` — @Embeddable VO (Provider 연동 후 검증 예정) + +### 예외 처리 + +| 클래스 | 상위 예외 | 설명 | +|---|---|---| +| `UserErrorCode` | ErrorCodeSpec | 에러 코드 enum | +| `UserNotFoundException` | NotFoundException (404) | 사용자를 찾을 수 없습니다 | +| `UserEmailDuplicateException` | ConflictException (409) | 이미 사용중인 이메일입니다 | +| `UserSlackIdDuplicateException` | ConflictException (409) | 이미 사용중인 SlackID입니다 | + +## 클린 아키텍처 + +```text +com.loopang.userservice +├── application/ +│ └── service/ +│ └── UserService.java # signup, login, logout, CRUD +├── domain/ +│ ├── entity/ +│ │ ├── User.java # @SQLRestriction, @Version +│ │ └── Courier.java +│ ├── exception/ +│ │ ├── UserErrorCode.java +│ │ ├── UserNotFoundException.java +│ │ ├── UserEmailDuplicateException.java +│ │ └── UserSlackIdDuplicateException.java +│ ├── repository/ +│ │ ├── UserRepository.java # 인터페이스 +│ │ └── CourierRepository.java +│ ├── service/ +│ │ ├── IdentityProvider.java # Keycloak 연동 인터페이스 +│ │ ├── RoleCheck.java +│ │ ├── HubProvider.java +│ │ ├── CompanyProvider.java +│ │ └── dto/ +│ │ └── TokenData.java # 도메인 레벨 토큰 DTO +│ └── vo/ +│ ├── UserType.java # MASTER, HUB, DELIVERY, COMPANY, PENDING +│ ├── DeliveryChargeType.java +│ ├── HubInfo.java # @Embeddable +│ └── CompanyInfo.java +├── infrastructure/ +│ ├── repository/ +│ │ ├── JpaUserRepository.java +│ │ └── JpaCourierRepository.java +│ └── keycloak/ +│ ├── KeycloakIdentityProvider.java # register, login, logout, withdraw, changePassword +│ └── KeycloakProperties.java # @ConfigurationProperties + @Validated +├── presentation/ +│ ├── controller/ +│ │ └── UserController.java # 인증 + CRUD + 권한 체크 +│ └── dto/ +│ ├── SignupRequestDto.java +│ ├── LoginRequestDto.java +│ ├── LogoutRequestDto.java +│ ├── UserUpdateRequestDto.java +│ └── response/ +│ ├── SignupResponseDto.java +│ ├── TokenResponseDto.java +│ └── UserResponseDto.java +└── config/ +``` + +## 로컬 실행 + +### 사전 조건 +- Eureka Server (18761) +- Config Server (18888) +- PostgreSQL Docker +- Keycloak (13300) + +### 환경변수 (IntelliJ Run Configuration) +```text +DB_URL=localhost:5432/user;DB_USERNAME=postgres;DB_PASSWORD=본인비밀번호;KEYCLOAK_CLIENT_SECRET=본인시크릿 +``` + +### Keycloak 로컬 실행 +```bash +docker run -d --name keycloak -p 13300:13300 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:24.0.0 start-dev --http-port=13300 --http-enabled=true --hostname-strict=false + +# SSL 끄기 (master + my-realm) +docker exec -it keycloak /opt/keycloak/bin/kcadm.sh config credentials --server http://localhost:13300 --realm master --user admin --password admin +docker exec -it keycloak /opt/keycloak/bin/kcadm.sh update realms/master -s sslRequired=NONE +docker exec -it keycloak /opt/keycloak/bin/kcadm.sh update realms/my-realm -s sslRequired=NONE --server http://localhost:13300 --realm master --user admin --password admin +``` + +### Keycloak 설정 +- 관리 콘솔: `http://localhost:13300` (admin/admin) +- Realm: `my-realm` +- Client: `loopang-client` (Client authentication: On, Direct Access Grants: On) + +## 구현 현황 + +### 완료 +- [x] User/Courier 엔티티 (@SQLRestriction, @Version, BaseUserEntity) +- [x] Keycloak Admin Client 연동 (register, login, logout, withdraw, changePassword) +- [x] KeycloakProperties (@ConfigurationProperties + @Validated) +- [x] 회원가입 API (MASTER 권한 차단, 보상 트랜잭션) +- [x] 로그인/로그아웃 API (Keycloak 토큰 발급/무효화) +- [x] /me 엔드포인트 (@RequestHeader 방식) +- [x] 사용자 CRUD (단건조회, 목록조회, 수정, 삭제) + MASTER 권한 체크 +- [x] 삭제 시 requesterId 전달 (감사 추적) +- [x] 예외 분리 + 개인정보 미노출 +- [x] TokenData 도메인 레벨 분리 (계층 위반 해소) +- [x] 코드래빗 리뷰 전체 반영 + +### TODO +- [ ] 검색 필터 (keyword, role, user_id — QueryDSL) +- [ ] CourierService + CourierController +- [ ] SecurityUtil 전환 (common publish 후 팀 전체 일괄) +- [ ] softDelete → delete 전환 (Keycloak 탈퇴 동기화) +- [ ] HubProvider 구현 (Feign Client → hub-service) +- [ ] CompanyProvider 구현 (Feign Client → company-service) +- [ ] Dockerfile + Docker 배포 +- [ ] GitHub Actions CI/CD diff --git a/build.gradle b/build.gradle index f3830aa..124d2d7 100644 --- a/build.gradle +++ b/build.gradle @@ -1,42 +1,78 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.5.13' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '3.5.13' + id 'io.spring.dependency-management' version '1.1.7' } group = 'com.loopang' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } } + configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() + maven { + url = uri("https://maven.pkg.github.com/MSA-Service-12th/common") + credentials { + username = project.findProperty("gpr.user") ?: System.getenv("GITHUB_USERNAME") + password = project.findProperty("gpr.token") ?: System.getenv("GITHUB_TOKEN") + } + } } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - runtimeOnly 'org.postgresql:postgresql' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // 공통 모듈 + implementation 'com.loopang:common:0.0.4-SNAPSHOT' + //keycloak + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.keycloak:keycloak-admin-client:24.0.0' + // eureka, config + implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' + implementation 'org.springframework.cloud:spring-cloud-starter-config' + //seucrity는 다시 비활성화해야함 +// implementation 'org.springframework.boot:spring-boot-starter-security' + + //DB + runtimeOnly 'org.postgresql:postgresql' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + //queryDSL & lombok + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + annotationProcessor 'org.projectlombok:lombok' + compileOnly 'org.projectlombok:lombok' + + //test + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + testRuntimeOnly 'com.h2database:h2' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} +ext { + set('springCloudVersion', "2025.0.1") } -tasks.named('test') { - useJUnitPlatform() +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } } + +tasks.named('test') { + useJUnitPlatform() +} \ No newline at end of file diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml new file mode 100644 index 0000000..c7a7f77 --- /dev/null +++ b/docker-compose-dev.yml @@ -0,0 +1,13 @@ +services: + postgres: + image: postgres:17 + container_name: loopang-db + restart: always + environment: + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ${DB} + ports: + - "5432:5432" + volumes: + - ./postgres_data:/var/lib/postgresql \ No newline at end of file diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/settings.gradle b/settings.gradle index e2257e1..5ac3272 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = 'userservice' +rootProject.name = 'userservice' \ No newline at end of file diff --git a/src/main/java/com/loopang/userservice/UserserviceApplication.java b/src/main/java/com/loopang/userservice/UserserviceApplication.java index 139bdf5..aa23977 100644 --- a/src/main/java/com/loopang/userservice/UserserviceApplication.java +++ b/src/main/java/com/loopang/userservice/UserserviceApplication.java @@ -2,12 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; @SpringBootApplication +@EnableDiscoveryClient public class UserserviceApplication { - public static void main(String[] args) { - SpringApplication.run(UserserviceApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(UserserviceApplication.class, args); + } -} +} \ No newline at end of file diff --git a/src/main/java/com/loopang/userservice/application/service/UserService.java b/src/main/java/com/loopang/userservice/application/service/UserService.java new file mode 100644 index 0000000..9d6c882 --- /dev/null +++ b/src/main/java/com/loopang/userservice/application/service/UserService.java @@ -0,0 +1,126 @@ +package com.loopang.userservice.application.service; + +import com.loopang.userservice.domain.entity.User; +import com.loopang.common.exception.ForbiddenException; +import com.loopang.userservice.domain.exception.UserEmailDuplicateException; +import com.loopang.userservice.domain.exception.UserNotFoundException; +import com.loopang.userservice.domain.exception.UserSlackIdDuplicateException; +import com.loopang.userservice.domain.vo.UserType; +import com.loopang.userservice.domain.repository.UserRepository; +import com.loopang.userservice.domain.service.IdentityProvider; +import com.loopang.userservice.domain.vo.CompanyInfo; +import com.loopang.userservice.domain.vo.HubInfo; +import com.loopang.userservice.presentation.dto.LoginRequestDto; +import com.loopang.userservice.presentation.dto.SignupRequestDto; +import com.loopang.userservice.presentation.dto.UserUpdateRequestDto; +import com.loopang.userservice.presentation.dto.response.SignupResponseDto; +import com.loopang.userservice.domain.service.dto.TokenData; +import com.loopang.userservice.presentation.dto.response.UserResponseDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + private final IdentityProvider identityProvider; + + @Transactional + public SignupResponseDto signup(SignupRequestDto request) { + if (request.getRole() == UserType.MASTER) { + throw new ForbiddenException("MASTER 권한은 직접 지정할 수 없습니다."); + } + + if (userRepository.existsByEmail(request.getEmail())) { + throw new UserEmailDuplicateException(request.getEmail()); + } + if (userRepository.existsBySlackId(request.getSlackId())) { + throw new UserSlackIdDuplicateException(request.getSlackId()); + } + + UUID keycloakUserId = identityProvider.register(request.getEmail(), request.getPassword()); + + try { + User user = User.builder() + .id(keycloakUserId) + .email(request.getEmail()) + .name(request.getName()) + .slackId(request.getSlackId()) + .role(request.getRole()) + .hubInfo(new HubInfo(request.getHubId(), "")) + .companyInfo(request.getCompanyId() != null + ? new CompanyInfo(request.getCompanyId(), "") + : null) + .approved(false) + .build(); + + return SignupResponseDto.from(userRepository.save(user)); + } catch (Exception e) { + log.error("DB 저장 실패, Keycloak 유저 롤백: {}", keycloakUserId); + try { + identityProvider.withdraw(keycloakUserId); + } catch (Exception rollbackEx) { + log.error("Keycloak 롤백 실패: {}", keycloakUserId, rollbackEx); + } + throw e; + } + } + + public TokenData login(LoginRequestDto request) { + return identityProvider.login(request.getEmail(), request.getPassword()); + } + + public void logout(String refreshToken) { + identityProvider.logout(refreshToken); + } + + public UserResponseDto getMe(UUID userId) { + return UserResponseDto.from(findUserById(userId)); + } + + public UserResponseDto getUser(UUID userId) { + return UserResponseDto.from(findUserById(userId)); + } + + public Page getUsers(Pageable pageable) { + return userRepository.findAll(pageable).map(UserResponseDto::from); + } + + @Transactional + public UserResponseDto updateUser(UUID userId, UserUpdateRequestDto request) { + User user = findUserById(userId); + + HubInfo hubInfo = request.getHubId() != null + ? new HubInfo(request.getHubId(), "") + : null; + CompanyInfo companyInfo = request.getCompanyId() != null + ? new CompanyInfo(request.getCompanyId(), "") + : null; + + user.update(request.getName(), request.getSlackId(), request.getRole(), + hubInfo, companyInfo, request.getApproved()); + + return UserResponseDto.from(user); + } + + @Transactional + public void deleteUser(UUID userId, UUID requesterId) { + User user = findUserById(userId); + // TODO: SecurityUtil + RoleCheck 연동 후 user.delete(masterId, roleCheck, identityProvider) 전환 + user.softDelete(requesterId); + } + + private User findUserById(UUID userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new UserNotFoundException(userId)); + } +} diff --git a/src/main/java/com/loopang/userservice/domain/entity/Courier.java b/src/main/java/com/loopang/userservice/domain/entity/Courier.java new file mode 100644 index 0000000..f3d54bc --- /dev/null +++ b/src/main/java/com/loopang/userservice/domain/entity/Courier.java @@ -0,0 +1,54 @@ +package com.loopang.userservice.domain.entity; + +import com.loopang.common.domain.BaseUserEntity; +import com.loopang.userservice.domain.vo.DeliveryChargeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Getter +@Table(name = "p_courier") +@SQLRestriction("deleted_at IS NULL") +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Courier extends BaseUserEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "courier_id") + private UUID id; + + @Version + private int version; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(length = 10, nullable = false, name = "delivery_charge_type") + @Enumerated(EnumType.STRING) + private DeliveryChargeType type; + + @Column(name = "delivery_turn", nullable = false) + private int deliveryTurn; + + public void assignDeliveryTurn(Integer turn) { + this.deliveryTurn = turn; + } +} diff --git a/src/main/java/com/loopang/userservice/domain/entity/User.java b/src/main/java/com/loopang/userservice/domain/entity/User.java new file mode 100644 index 0000000..fa62a5f --- /dev/null +++ b/src/main/java/com/loopang/userservice/domain/entity/User.java @@ -0,0 +1,117 @@ +package com.loopang.userservice.domain.entity; + +import static com.loopang.userservice.domain.vo.UserType.MASTER; + +import com.loopang.common.domain.BaseUserEntity; +import com.loopang.common.exception.BadRequestException; +import com.loopang.common.exception.ForbiddenException; +import com.loopang.userservice.domain.service.IdentityProvider; +import com.loopang.userservice.domain.service.RoleCheck; +import com.loopang.userservice.domain.vo.CompanyInfo; +import com.loopang.userservice.domain.vo.HubInfo; +import com.loopang.userservice.domain.vo.UserType; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLRestriction; +import org.springframework.util.StringUtils; + +@Entity +@Getter +@Builder +@Table(name = "p_user") +@SQLRestriction("deleted_at IS NULL") +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseUserEntity { + + @Id + private UUID id; + + @Version + private int version; + + @Column(length = 50, nullable = false) + private String email; + + @Column(length = 50, nullable = false) + private String name; + + @Column(length = 100, nullable = false) + private String slackId; + + @Column(length = 10, nullable = false) + @Enumerated(EnumType.STRING) + private UserType role; + + @Embedded + private HubInfo hubInfo; + + @Embedded + private CompanyInfo companyInfo; + + private boolean approved; + + public boolean isEnabled() { + return this.approved && this.getDeletedAt() == null; + } + + public void update(String name, String slackId, UserType role, HubInfo hubInfo, CompanyInfo companyInfo, Boolean approved) { + if (name != null) this.name = name; + if (slackId != null) this.slackId = slackId; + if (role != null) this.role = role; + if (hubInfo != null) this.hubInfo = hubInfo; + if (companyInfo != null) this.companyInfo = companyInfo; + if (approved != null) this.approved = approved; + } + + // TODO: SecurityUtil 전환 후 softDelete 제거, delete()로 통일 + // TODO: deletedBy에 현재 유저 ID 전달 (SecurityUtil.getCurrentUserIdOrThrow()) + public void softDelete(UUID deletedBy) { + if (this.getDeletedAt() != null) { + return; + } + super.delete(deletedBy); + } + + private void checkMasterId(UUID masterId) { + if (masterId == null) { + throw new BadRequestException("관리자 아이디가 누락되었습니다."); + } + } + + // TODO: SecurityUtil 전환 후 이 메서드 사용 + // TODO: Keycloak-DB 정합성 보장을 위해 Outbox 패턴 또는 보상 트랜잭션 적용 + public void delete(UUID masterId, RoleCheck roleCheck, IdentityProvider identityProvider) { + if (this.getDeletedAt() != null) { + return; + } + checkMasterId(masterId); + if (roleCheck == null) { + throw new BadRequestException("권한 검증기가 누락되었습니다."); + } + if (identityProvider == null) { + throw new BadRequestException("인증 제공자가 누락되었습니다."); + } + checkMaster(roleCheck); + identityProvider.withdraw(id); + super.delete(masterId); + } + + private void checkMaster(RoleCheck roleCheck) { + if (!roleCheck.hasRole(MASTER)) { + throw new ForbiddenException(MASTER.getDescription() + " 권한이 필요합니다."); + } + } +} diff --git a/src/main/java/com/loopang/userservice/domain/exception/UserEmailDuplicateException.java b/src/main/java/com/loopang/userservice/domain/exception/UserEmailDuplicateException.java new file mode 100644 index 0000000..6748143 --- /dev/null +++ b/src/main/java/com/loopang/userservice/domain/exception/UserEmailDuplicateException.java @@ -0,0 +1,10 @@ +package com.loopang.userservice.domain.exception; + +import com.loopang.common.exception.ConflictException; + +public class UserEmailDuplicateException extends ConflictException { + + public UserEmailDuplicateException(String email) { + super("이미 사용중인 이메일입니다."); + } +} diff --git a/src/main/java/com/loopang/userservice/domain/exception/UserErrorCode.java b/src/main/java/com/loopang/userservice/domain/exception/UserErrorCode.java new file mode 100644 index 0000000..e7f4587 --- /dev/null +++ b/src/main/java/com/loopang/userservice/domain/exception/UserErrorCode.java @@ -0,0 +1,28 @@ +package com.loopang.userservice.domain.exception; + +import com.loopang.common.exception.ErrorCodeSpec; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum UserErrorCode implements ErrorCodeSpec { + + USER_NOT_FOUND("USER_NOT_FOUND", HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다.", null), + USER_EMAIL_DUPLICATE("USER_EMAIL_DUPLICATE", HttpStatus.CONFLICT, "이미 사용중인 이메일입니다.", "email"), + USER_SLACK_ID_DUPLICATE("USER_SLACK_ID_DUPLICATE", HttpStatus.CONFLICT, "이미 사용중인 SlackID 입니다.", "slackId"), + USER_ALREADY_DELETED("USER_ALREADY_DELETED", HttpStatus.BAD_REQUEST, "이미 탈퇴한 사용자입니다.", null), + MASTER_ONLY("MASTER_ONLY", HttpStatus.FORBIDDEN, "마스터 관리자만 수행할 수 있는 작업입니다.", null), + KEYCLOAK_REGISTER_FAILED("KEYCLOAK_REGISTER_FAILED", HttpStatus.INTERNAL_SERVER_ERROR, "Keycloak 회원가입에 실패했습니다.", null); + + private final String code; + private final HttpStatus status; + private final String message; + private final String field; + + UserErrorCode(String code, HttpStatus status, String message, String field) { + this.code = code; + this.status = status; + this.message = message; + this.field = field; + } +} diff --git a/src/main/java/com/loopang/userservice/domain/exception/UserNotFoundException.java b/src/main/java/com/loopang/userservice/domain/exception/UserNotFoundException.java new file mode 100644 index 0000000..f335c40 --- /dev/null +++ b/src/main/java/com/loopang/userservice/domain/exception/UserNotFoundException.java @@ -0,0 +1,12 @@ +package com.loopang.userservice.domain.exception; + +import com.loopang.common.exception.NotFoundException; + +import java.util.UUID; + +public class UserNotFoundException extends NotFoundException { + + public UserNotFoundException(UUID userId) { + super("사용자를 찾을 수 없습니다. User ID: " + userId); + } +} diff --git a/src/main/java/com/loopang/userservice/domain/exception/UserSlackIdDuplicateException.java b/src/main/java/com/loopang/userservice/domain/exception/UserSlackIdDuplicateException.java new file mode 100644 index 0000000..03bfff1 --- /dev/null +++ b/src/main/java/com/loopang/userservice/domain/exception/UserSlackIdDuplicateException.java @@ -0,0 +1,10 @@ +package com.loopang.userservice.domain.exception; + +import com.loopang.common.exception.ConflictException; + +public class UserSlackIdDuplicateException extends ConflictException { + + public UserSlackIdDuplicateException(String slackId) { + super("이미 사용중인 SlackID 입니다."); + } +} diff --git a/src/main/java/com/loopang/userservice/domain/repository/CourierRepository.java b/src/main/java/com/loopang/userservice/domain/repository/CourierRepository.java new file mode 100644 index 0000000..b2cd15e --- /dev/null +++ b/src/main/java/com/loopang/userservice/domain/repository/CourierRepository.java @@ -0,0 +1,4 @@ +package com.loopang.userservice.domain.repository; + +public interface CourierRepository { +} \ No newline at end of file diff --git a/src/main/java/com/loopang/userservice/domain/repository/UserRepository.java b/src/main/java/com/loopang/userservice/domain/repository/UserRepository.java new file mode 100644 index 0000000..7d03227 --- /dev/null +++ b/src/main/java/com/loopang/userservice/domain/repository/UserRepository.java @@ -0,0 +1,21 @@ +package com.loopang.userservice.domain.repository; + +import com.loopang.userservice.domain.entity.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; +import java.util.UUID; + +public interface UserRepository { + + User save(User user); + + Optional findById(UUID id); + + Page findAll(Pageable pageable); + + boolean existsByEmail(String email); + + boolean existsBySlackId(String slackId); +} diff --git a/src/main/java/com/loopang/userservice/domain/service/CompanyProvider.java b/src/main/java/com/loopang/userservice/domain/service/CompanyProvider.java new file mode 100644 index 0000000..c7b65ab --- /dev/null +++ b/src/main/java/com/loopang/userservice/domain/service/CompanyProvider.java @@ -0,0 +1,9 @@ +package com.loopang.userservice.domain.service; + +import com.loopang.userservice.domain.vo.CompanyInfo; +import java.util.UUID; + +public interface CompanyProvider { + + CompanyInfo get(UUID storeId); +} \ No newline at end of file diff --git a/src/main/java/com/loopang/userservice/domain/service/HubProvider.java b/src/main/java/com/loopang/userservice/domain/service/HubProvider.java new file mode 100644 index 0000000..118ede1 --- /dev/null +++ b/src/main/java/com/loopang/userservice/domain/service/HubProvider.java @@ -0,0 +1,9 @@ +package com.loopang.userservice.domain.service; + +import com.loopang.userservice.domain.vo.HubInfo; +import java.util.UUID; + +public interface HubProvider { + + HubInfo get(UUID hubId); +} \ No newline at end of file diff --git a/src/main/java/com/loopang/userservice/domain/service/IdentityProvider.java b/src/main/java/com/loopang/userservice/domain/service/IdentityProvider.java new file mode 100644 index 0000000..2fb492e --- /dev/null +++ b/src/main/java/com/loopang/userservice/domain/service/IdentityProvider.java @@ -0,0 +1,18 @@ +package com.loopang.userservice.domain.service; + +import com.loopang.userservice.domain.service.dto.TokenData; + +import java.util.UUID; + +public interface IdentityProvider { + + UUID register(String email, String password); + + TokenData login(String email, String password); + + void logout(String refreshToken); + + void withdraw(UUID userId); + + void changePassword(UUID id, String newPassword); +} diff --git a/src/main/java/com/loopang/userservice/domain/service/RoleCheck.java b/src/main/java/com/loopang/userservice/domain/service/RoleCheck.java new file mode 100644 index 0000000..eb68f26 --- /dev/null +++ b/src/main/java/com/loopang/userservice/domain/service/RoleCheck.java @@ -0,0 +1,15 @@ +package com.loopang.userservice.domain.service; + +import com.loopang.userservice.domain.vo.UserType; +import java.util.List; +import java.util.UUID; + +public interface RoleCheck { + + boolean hasRole(UserType type); + + boolean hasRole(List types); + + boolean isMine(UUID id); + +} \ No newline at end of file diff --git a/src/main/java/com/loopang/userservice/domain/service/dto/TokenData.java b/src/main/java/com/loopang/userservice/domain/service/dto/TokenData.java new file mode 100644 index 0000000..b7bb7d2 --- /dev/null +++ b/src/main/java/com/loopang/userservice/domain/service/dto/TokenData.java @@ -0,0 +1,15 @@ +package com.loopang.userservice.domain.service.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class TokenData { + + private String accessToken; + private String refreshToken; + private String tokenType; + private long expiresIn; + private long refreshExpiresIn; +} diff --git a/src/main/java/com/loopang/userservice/domain/vo/CompanyInfo.java b/src/main/java/com/loopang/userservice/domain/vo/CompanyInfo.java new file mode 100644 index 0000000..7d8b41b --- /dev/null +++ b/src/main/java/com/loopang/userservice/domain/vo/CompanyInfo.java @@ -0,0 +1,38 @@ +package com.loopang.userservice.domain.vo; + +import com.loopang.common.exception.BadRequestException; +import com.loopang.userservice.domain.service.CompanyProvider; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class CompanyInfo { + + @Column(name = "company_id") + private UUID companyId; + + @Column(length = 100, name = "company_name") + private String companyName; + + protected CompanyInfo(UUID id, CompanyProvider companyProvider) { + + if (id == null || companyProvider == null) { + throw new BadRequestException("소속 업체 등록/수정을 위한 필수 항목이 누락되었습니다."); + } + + CompanyInfo company = companyProvider.get(id); + if (company == null) { + throw new BadRequestException("소속 업체 등록/수정을 위한 필수 항목이 누락되었습니다."); + } + + this.companyId = id; + this.companyName = company.getCompanyName(); + } +} \ No newline at end of file diff --git a/src/main/java/com/loopang/userservice/domain/vo/DeliveryChargeType.java b/src/main/java/com/loopang/userservice/domain/vo/DeliveryChargeType.java new file mode 100644 index 0000000..ae19f6d --- /dev/null +++ b/src/main/java/com/loopang/userservice/domain/vo/DeliveryChargeType.java @@ -0,0 +1,13 @@ +package com.loopang.userservice.domain.vo; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum DeliveryChargeType { + HUB("허브 배송 담당자"), + COMPANY("업체 배송 담당자"); + + private final String description; +} \ No newline at end of file diff --git a/src/main/java/com/loopang/userservice/domain/vo/HubInfo.java b/src/main/java/com/loopang/userservice/domain/vo/HubInfo.java new file mode 100644 index 0000000..07ea682 --- /dev/null +++ b/src/main/java/com/loopang/userservice/domain/vo/HubInfo.java @@ -0,0 +1,50 @@ +package com.loopang.userservice.domain.vo; + +import com.loopang.common.exception.BadRequestException; +import com.loopang.userservice.domain.service.HubProvider; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.util.UUID; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor +public class HubInfo { + + @Column(name = "hub_id", nullable = false) + private UUID hubId; + + @Column(length = 50, name = "hub_name", nullable = false) + private String hubName; + + // TODO: HubProvider 구현 후 이 생성자 사용 + protected HubInfo(UUID id, HubProvider hubProvider) { + if (id == null || hubProvider == null) { + throw new BadRequestException("소속 허브 등록/수정을 위한 필수 항목이 누락되었습니다."); + } + HubInfo hub = hubProvider.get(id); + if (hub == null || hub.getHubId() == null || hub.getHubName() == null || hub.getHubName().isBlank()) { + throw new BadRequestException("소속 허브를 찾을 수 없습니다."); + } + this.hubId = hub.getHubId(); + this.hubName = hub.getHubName(); + } + + // TODO: HubProvider 연동 후 이 임시 생성자 제거하고 위 생성자로 통일 + public HubInfo(UUID hubId, String hubName) { + if (hubId == null) { + throw new BadRequestException("hubId는 필수입니다."); + } + this.hubId = hubId; + this.hubName = hubName != null ? hubName : ""; + // TODO: HubProvider 연동 후 아래 검증 활성화 + // if (hubName == null) { + // throw new BadRequestException("hubName은 필수입니다."); + // } + // if (hubName.length() > 50) { + // throw new BadRequestException("hubName 길이는 50자를 초과할 수 없습니다."); + // } + } +} diff --git a/src/main/java/com/loopang/userservice/domain/vo/UserType.java b/src/main/java/com/loopang/userservice/domain/vo/UserType.java new file mode 100644 index 0000000..d6d5c08 --- /dev/null +++ b/src/main/java/com/loopang/userservice/domain/vo/UserType.java @@ -0,0 +1,20 @@ +package com.loopang.userservice.domain.vo; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + + +@Getter +@RequiredArgsConstructor +public enum UserType { + MASTER("마스터 관리자"), + HUB("허브 관리자"), + DELIVERY("배송 담당자"), + COMPANY("업체 담당자"), + PENDING("대기"); + private final String description; + + public String toRole() { + return "ROLE_" + this.name(); + } +} \ No newline at end of file diff --git a/src/main/java/com/loopang/userservice/infrastructure/keycloak/KeycloakIdentityProvider.java b/src/main/java/com/loopang/userservice/infrastructure/keycloak/KeycloakIdentityProvider.java new file mode 100644 index 0000000..2cc5f57 --- /dev/null +++ b/src/main/java/com/loopang/userservice/infrastructure/keycloak/KeycloakIdentityProvider.java @@ -0,0 +1,170 @@ +package com.loopang.userservice.infrastructure.keycloak; + +import com.loopang.common.exception.InternalServerException; +import com.loopang.common.exception.UnAuthorizedException; +import com.loopang.userservice.domain.service.IdentityProvider; +import com.loopang.userservice.domain.service.dto.TokenData; +import jakarta.ws.rs.core.Response; +import lombok.extern.slf4j.Slf4j; +import org.keycloak.OAuth2Constants; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.KeycloakBuilder; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.UsersResource; +import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Slf4j +@Component +public class KeycloakIdentityProvider implements IdentityProvider { + + private final KeycloakProperties properties; + private final Keycloak keycloak; + private final RestTemplate restTemplate; + + public KeycloakIdentityProvider(KeycloakProperties properties) { + this.properties = properties; + this.keycloak = KeycloakBuilder.builder() + .serverUrl(properties.getServerUrl()) + .realm("master") + .username(properties.getUsername()) + .password(properties.getPassword()) + .clientId("admin-cli") + .build(); + this.restTemplate = new RestTemplate(); + } + + @Override + public UUID register(String email, String password) { + UsersResource usersResource = getRealmResource().users(); + + UserRepresentation user = new UserRepresentation(); + user.setUsername(email); + user.setEmail(email); + user.setEnabled(true); + user.setEmailVerified(true); + + CredentialRepresentation credential = new CredentialRepresentation(); + credential.setType(CredentialRepresentation.PASSWORD); + credential.setValue(password); + credential.setTemporary(false); + user.setCredentials(List.of(credential)); + + try (Response response = usersResource.create(user)) { + if (response.getStatus() == 201) { + String locationHeader = response.getHeaderString("Location"); + String userId = locationHeader.substring(locationHeader.lastIndexOf("/") + 1); + log.info("[Keycloak] 유저 등록 성공: email={}, id={}", email, userId); + return UUID.fromString(userId); + } else if (response.getStatus() == 409) { + throw new InternalServerException("Keycloak에 이미 등록된 이메일입니다: " + email); + } else { + throw new InternalServerException("Keycloak 유저 등록 실패: status=" + response.getStatus()); + } + } + } + + @Override + public TokenData login(String email, String password) { + String tokenUrl = properties.getServerUrl() + + "/realms/" + properties.getRealm() + + "/protocol/openid-connect/token"; + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", OAuth2Constants.PASSWORD); + params.add("client_id", properties.getClientId()); + params.add("client_secret", properties.getClientSecret()); + params.add("username", email); + params.add("password", password); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + try { + ResponseEntity response = restTemplate.exchange( + tokenUrl, + HttpMethod.POST, + new HttpEntity<>(params, headers), + Map.class + ); + + Map body = response.getBody(); + log.info("[Keycloak] 로그인 성공: {}", email); + + return TokenData.builder() + .accessToken((String) body.get("access_token")) + .refreshToken((String) body.get("refresh_token")) + .tokenType((String) body.get("token_type")) + .expiresIn(((Number) body.get("expires_in")).longValue()) + .refreshExpiresIn(((Number) body.get("refresh_expires_in")).longValue()) + .build(); + } catch (Exception e) { + log.error("[Keycloak] 로그인 실패: {}", email, e); + throw new UnAuthorizedException("이메일 또는 비밀번호가 올바르지 않습니다."); + } + } + + @Override + public void logout(String refreshToken) { + String logoutUrl = properties.getServerUrl() + + "/realms/" + properties.getRealm() + + "/protocol/openid-connect/logout"; + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("client_id", properties.getClientId()); + params.add("client_secret", properties.getClientSecret()); + params.add("refresh_token", refreshToken); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + try { + restTemplate.exchange(logoutUrl, HttpMethod.POST, new HttpEntity<>(params, headers), Void.class); + log.info("[Keycloak] 로그아웃 성공"); + } catch (Exception e) { + log.error("[Keycloak] 로그아웃 실패", e); + throw new InternalServerException("로그아웃에 실패했습니다."); + } + } + + @Override + public void withdraw(UUID userId) { + try { + getRealmResource().users().delete(userId.toString()); + log.info("[Keycloak] 유저 삭제 완료: {}", userId); + } catch (Exception e) { + log.error("[Keycloak] 유저 삭제 실패: {}", userId, e); + throw new InternalServerException("Keycloak 유저 삭제 실패"); + } + } + + @Override + public void changePassword(UUID userId, String newPassword) { + CredentialRepresentation credential = new CredentialRepresentation(); + credential.setType(CredentialRepresentation.PASSWORD); + credential.setValue(newPassword); + credential.setTemporary(false); + + try { + getRealmResource().users().get(userId.toString()).resetPassword(credential); + log.info("[Keycloak] 비밀번호 변경 완료: {}", userId); + } catch (Exception e) { + log.error("[Keycloak] 비밀번호 변경 실패: {}", userId, e); + throw new InternalServerException("Keycloak 비밀번호 변경 실패"); + } + } + + private RealmResource getRealmResource() { + return keycloak.realm(properties.getRealm()); + } +} diff --git a/src/main/java/com/loopang/userservice/infrastructure/keycloak/KeycloakProperties.java b/src/main/java/com/loopang/userservice/infrastructure/keycloak/KeycloakProperties.java new file mode 100644 index 0000000..a9724e3 --- /dev/null +++ b/src/main/java/com/loopang/userservice/infrastructure/keycloak/KeycloakProperties.java @@ -0,0 +1,34 @@ +package com.loopang.userservice.infrastructure.keycloak; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +@Getter +@Setter +@Component +@Validated +@ConfigurationProperties(prefix = "keycloak") +public class KeycloakProperties { + + @NotBlank(message = "keycloak.server-url은 필수입니다.") + private String serverUrl; + + @NotBlank(message = "keycloak.realm은 필수입니다.") + private String realm; + + @NotBlank(message = "keycloak.username은 필수입니다.") + private String username; + + @NotBlank(message = "keycloak.password는 필수입니다.") + private String password; + + @NotBlank(message = "keycloak.client-id는 필수입니다.") + private String clientId; + + @NotBlank(message = "keycloak.client-secret은 필수입니다.") + private String clientSecret; +} diff --git a/src/main/java/com/loopang/userservice/infrastructure/repository/JpaCourierRepository.java b/src/main/java/com/loopang/userservice/infrastructure/repository/JpaCourierRepository.java new file mode 100644 index 0000000..a14d425 --- /dev/null +++ b/src/main/java/com/loopang/userservice/infrastructure/repository/JpaCourierRepository.java @@ -0,0 +1,12 @@ +package com.loopang.userservice.infrastructure.repository; + +import com.loopang.userservice.domain.entity.Courier; +import com.loopang.userservice.domain.repository.CourierRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface JpaCourierRepository extends JpaRepository, CourierRepository { +} \ No newline at end of file diff --git a/src/main/java/com/loopang/userservice/infrastructure/repository/JpaUserRepository.java b/src/main/java/com/loopang/userservice/infrastructure/repository/JpaUserRepository.java new file mode 100644 index 0000000..37b20fb --- /dev/null +++ b/src/main/java/com/loopang/userservice/infrastructure/repository/JpaUserRepository.java @@ -0,0 +1,12 @@ +package com.loopang.userservice.infrastructure.repository; + +import com.loopang.userservice.domain.entity.User; +import com.loopang.userservice.domain.repository.UserRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface JpaUserRepository extends JpaRepository, UserRepository { +} \ No newline at end of file diff --git a/src/main/java/com/loopang/userservice/presentation/controller/UserController.java b/src/main/java/com/loopang/userservice/presentation/controller/UserController.java new file mode 100644 index 0000000..9376d4d --- /dev/null +++ b/src/main/java/com/loopang/userservice/presentation/controller/UserController.java @@ -0,0 +1,95 @@ +package com.loopang.userservice.presentation.controller; + +import com.loopang.common.exception.ForbiddenException; +import com.loopang.common.response.CommonResponse; +import com.loopang.common.response.PageInfo; +import com.loopang.userservice.application.service.UserService; +import com.loopang.userservice.domain.vo.UserType; +import com.loopang.userservice.presentation.dto.LoginRequestDto; +import com.loopang.userservice.presentation.dto.LogoutRequestDto; +import com.loopang.userservice.presentation.dto.SignupRequestDto; +import com.loopang.userservice.presentation.dto.UserUpdateRequestDto; +import com.loopang.userservice.presentation.dto.response.SignupResponseDto; +import com.loopang.userservice.presentation.dto.response.TokenResponseDto; +import com.loopang.userservice.presentation.dto.response.UserResponseDto; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public CommonResponse signup(@Valid @RequestBody SignupRequestDto request) { + return CommonResponse.success(userService.signup(request), "사용자 생성 요청이 전달되었습니다."); + } + + @PostMapping("/login") + public CommonResponse login(@Valid @RequestBody LoginRequestDto request) { + return CommonResponse.success(TokenResponseDto.from(userService.login(request)), "로그인에 성공했습니다."); + } + + @PostMapping("/logout") + public CommonResponse logout(@Valid @RequestBody LogoutRequestDto request) { + userService.logout(request.getRefreshToken()); + return CommonResponse.success(null, "로그아웃에 성공했습니다."); + } + + @GetMapping("/me") + public CommonResponse getMe(@RequestHeader("X-User-UUID") UUID userId) { + return CommonResponse.success(userService.getMe(userId), "내 정보 조회에 성공했습니다."); + } + + @GetMapping("/{userId}") + public CommonResponse getUser( + @PathVariable UUID userId, + @RequestHeader("X-User-Role") String userRole) { + checkMaster(userRole); + return CommonResponse.success(userService.getUser(userId), "사용자 조회에 성공했습니다."); + } + + @GetMapping + public CommonResponse> getUsers( + Pageable pageable, + @RequestHeader("X-User-Role") String userRole) { + checkMaster(userRole); + Page page = userService.getUsers(pageable); + return CommonResponse.success(page.getContent(), "사용자 목록 조회에 성공했습니다.", PageInfo.from(page)); + } + + @PatchMapping("/{userId}") + public CommonResponse updateUser( + @PathVariable UUID userId, + @RequestHeader("X-User-Role") String userRole, + @Valid @RequestBody UserUpdateRequestDto request) { + checkMaster(userRole); + return CommonResponse.success(userService.updateUser(userId, request), "사용자 정보가 수정되었습니다."); + } + + @DeleteMapping("/{userId}") + public CommonResponse deleteUser( + @PathVariable UUID userId, + @RequestHeader("X-User-UUID") UUID requesterId, + @RequestHeader("X-User-Role") String userRole) { + checkMaster(userRole); + userService.deleteUser(userId, requesterId); + return CommonResponse.success(null, "사용자가 삭제되었습니다."); + } + + private void checkMaster(String userRole) { + if (!UserType.MASTER.toRole().equals(userRole)) { + throw new ForbiddenException("마스터 관리자만 수행할 수 있는 작업입니다."); + } + } +} diff --git a/src/main/java/com/loopang/userservice/presentation/dto/LoginRequestDto.java b/src/main/java/com/loopang/userservice/presentation/dto/LoginRequestDto.java new file mode 100644 index 0000000..a5eefe9 --- /dev/null +++ b/src/main/java/com/loopang/userservice/presentation/dto/LoginRequestDto.java @@ -0,0 +1,18 @@ +package com.loopang.userservice.presentation.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class LoginRequestDto { + + @NotBlank(message = "이메일을 입력해주세요.") + private String email; + + @NotBlank(message = "비밀번호를 입력해주세요.") + private String password; +} diff --git a/src/main/java/com/loopang/userservice/presentation/dto/LogoutRequestDto.java b/src/main/java/com/loopang/userservice/presentation/dto/LogoutRequestDto.java new file mode 100644 index 0000000..3e68285 --- /dev/null +++ b/src/main/java/com/loopang/userservice/presentation/dto/LogoutRequestDto.java @@ -0,0 +1,15 @@ +package com.loopang.userservice.presentation.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class LogoutRequestDto { + + @NotBlank(message = "refreshToken은 필수입니다.") + private String refreshToken; +} diff --git a/src/main/java/com/loopang/userservice/presentation/dto/SignupRequestDto.java b/src/main/java/com/loopang/userservice/presentation/dto/SignupRequestDto.java new file mode 100644 index 0000000..2f15e42 --- /dev/null +++ b/src/main/java/com/loopang/userservice/presentation/dto/SignupRequestDto.java @@ -0,0 +1,40 @@ +package com.loopang.userservice.presentation.dto; + +import com.loopang.userservice.domain.vo.UserType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +public class SignupRequestDto { + + @NotBlank(message = "아이디를 입력해주세요.") + @Size(min = 4, max = 10, message = "아이디는 4자 이상 10자 이하로 입력해주세요.") + private String email; + @NotBlank(message = "비밀번호를 입력해주세요.") + @Size(min = 8, max = 15, message = "비밀번호는 8자 이상 15자 이하로 입력해주세요.") + @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]+$", + message = "비밀번호는 알파벳 대소문자, 숫자, 특수문자를 최소 하나씩 포함해야 합니다.") + private String password; + @NotBlank(message = "이름을 입력해주세요.") + private String name; + + @NotNull(message = "slackId를 입력해주세요") + private String slackId; + + @NotNull(message = "소속 허브를 선택해주세요.") + private UUID hubId; + + private UUID companyId; + + @NotNull(message = "권한을 선택해주세요.") + private UserType role; +} \ No newline at end of file diff --git a/src/main/java/com/loopang/userservice/presentation/dto/UserUpdateRequestDto.java b/src/main/java/com/loopang/userservice/presentation/dto/UserUpdateRequestDto.java new file mode 100644 index 0000000..5935b88 --- /dev/null +++ b/src/main/java/com/loopang/userservice/presentation/dto/UserUpdateRequestDto.java @@ -0,0 +1,21 @@ +package com.loopang.userservice.presentation.dto; + +import com.loopang.userservice.domain.vo.UserType; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class UserUpdateRequestDto { + + private String name; + private String slackId; + private UserType role; + private UUID hubId; + private UUID companyId; + private Boolean approved; +} diff --git a/src/main/java/com/loopang/userservice/presentation/dto/response/SignupResponseDto.java b/src/main/java/com/loopang/userservice/presentation/dto/response/SignupResponseDto.java new file mode 100644 index 0000000..4bc215e --- /dev/null +++ b/src/main/java/com/loopang/userservice/presentation/dto/response/SignupResponseDto.java @@ -0,0 +1,30 @@ +package com.loopang.userservice.presentation.dto.response; + +import com.loopang.userservice.domain.entity.User; +import com.loopang.userservice.domain.vo.UserType; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Builder +public class SignupResponseDto { + + private String slackId; + private String name; + private UserType role; + private UUID hubId; + private LocalDateTime createdAt; + + public static SignupResponseDto from(User user) { + return SignupResponseDto.builder() + .slackId(user.getSlackId()) + .name(user.getName()) + .role(user.getRole()) + .hubId(user.getHubInfo() != null ? user.getHubInfo().getHubId() : null) + .createdAt(user.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/loopang/userservice/presentation/dto/response/TokenResponseDto.java b/src/main/java/com/loopang/userservice/presentation/dto/response/TokenResponseDto.java new file mode 100644 index 0000000..d6029ec --- /dev/null +++ b/src/main/java/com/loopang/userservice/presentation/dto/response/TokenResponseDto.java @@ -0,0 +1,26 @@ +package com.loopang.userservice.presentation.dto.response; + +import com.loopang.userservice.domain.service.dto.TokenData; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class TokenResponseDto { + + private String accessToken; + private String refreshToken; + private String tokenType; + private long expiresIn; + private long refreshExpiresIn; + + public static TokenResponseDto from(TokenData data) { + return TokenResponseDto.builder() + .accessToken(data.getAccessToken()) + .refreshToken(data.getRefreshToken()) + .tokenType(data.getTokenType()) + .expiresIn(data.getExpiresIn()) + .refreshExpiresIn(data.getRefreshExpiresIn()) + .build(); + } +} diff --git a/src/main/java/com/loopang/userservice/presentation/dto/response/UserResponseDto.java b/src/main/java/com/loopang/userservice/presentation/dto/response/UserResponseDto.java new file mode 100644 index 0000000..3a38f97 --- /dev/null +++ b/src/main/java/com/loopang/userservice/presentation/dto/response/UserResponseDto.java @@ -0,0 +1,52 @@ +package com.loopang.userservice.presentation.dto.response; + +import com.loopang.userservice.domain.entity.User; +import com.loopang.userservice.domain.vo.UserType; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Builder +public class UserResponseDto { + + private UUID userId; + private String email; + private String name; + private String slackId; + private UserType role; + private UUID hubId; + private String hubName; + private UUID companyId; + private String companyName; + private boolean approved; + private boolean enabled; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static UserResponseDto from(User user) { + var builder = UserResponseDto.builder() + .userId(user.getId()) + .email(user.getEmail()) + .name(user.getName()) + .slackId(user.getSlackId()) + .role(user.getRole()) + .approved(user.isApproved()) + .enabled(user.isEnabled()) + .createdAt(user.getCreatedAt()) + .updatedAt(user.getUpdatedAt()); + + if (user.getHubInfo() != null) { + builder.hubId(user.getHubInfo().getHubId()) + .hubName(user.getHubInfo().getHubName()); + } + if (user.getCompanyInfo() != null) { + builder.companyId(user.getCompanyInfo().getCompanyId()) + .companyName(user.getCompanyInfo().getCompanyName()); + } + + return builder.build(); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index a04d93d..284ec7e 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,27 @@ spring: application: - name: userservice + name: user-service + config: + import: 'optional:configserver:' + cloud: + config: + discovery: + enabled: true + service-id: config-server + +eureka: + client: + fetch-registry: true + register-with-eureka: true + service-url: + defaultZone: ${EUREKA_SERVER_URL:http://localhost:18761/eureka} + import: 'optional:configserver:' + + +keycloak: + server-url: ${KEYCLOAK_SERVER_URL:http://localhost:13300} + realm: ${REALM:my-realm} + username: ${KEYCLOAK_USERNAME:admin} + password: ${KEYCLOAK_PASSWORD:admin} + client-id: ${KEYCLOAK_CLIENT_ID:loopang-client} + client-secret: ${KEYCLOAK_CLIENT_SECRET} \ No newline at end of file