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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,20 @@ R2_SECRET_KEY=replace-with-r2-secret-key
# R2는 region을 auto로 고정한다 (변경 불필요)
R2_REGION=auto
S3_UPLOAD_URL_EXPIRY_SECONDS=600

### infrastructure:client — KConnect OAuth ###
# KConnect 측 요청으로 API 명세(주소·경로·응답 필드)는 저장소에 남기지 않는다. 실제 값은 팀 내부 문서를 참고한다
KCONNECT_BASE_URL=replace-with-kconnect-base-url
KCONNECT_TOKEN_PATH=replace-with-token-path
# KConnect 관리자에게 받은 값. Client Secret은 코드·로그에 남기지 않는다
KCONNECT_CLIENT_ID=replace-with-kconnect-client-id
KCONNECT_CLIENT_SECRET=replace-with-kconnect-client-secret
# KConnect에 등록한 redirect URI (쉼표 구분). 운영 환경에는 localhost를 넣지 않는다
KCONNECT_ALLOWED_REDIRECT_URIS=kmustream://oauth/kconnect,http://localhost:5173/oauth/kconnect
# 사용자 정보 API 경로와 응답에서 읽을 필드 이름
KCONNECT_USER_INFO_PATH=replace-with-user-info-path
KCONNECT_USER_INFO_ID_FIELD=replace-with-field-name
KCONNECT_USER_INFO_STUDENT_ID_FIELD=replace-with-field-name
KCONNECT_USER_INFO_NAME_FIELD=replace-with-field-name
KCONNECT_USER_INFO_MAJOR_FIELD=replace-with-field-name
KCONNECT_USER_INFO_ACADEMIC_STATUS_FIELD=replace-with-field-name
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
package kr.ac.kookmin.stream.member.domain.member.domain;

import java.util.Arrays;
import kr.ac.kookmin.stream.common.BusinessException;
import lombok.AllArgsConstructor;

/**
* 학부. 학생회 부서(CouncilDepartment)와는 다른 개념이다.
*/
@AllArgsConstructor
public enum Department {
AI,
SW
AI("인공지능전공"),
SW("소프트웨어전공");

// 로그인 provider가 주는 소속 문자열에서 찾는 전공명
private final String majorName;

/** 소속 문자열을 학부로 바꾼다. 소프트웨어융합대학 전공이 아니면 가입할 수 없다. */
public static Department fromMajor(String major) {
if (major == null) {
throw new BusinessException(MemberErrorCode.DEPARTMENT_NOT_ALLOWED);
}
return Arrays.stream(values())
.filter(department -> major.contains(department.majorName))
.findFirst()
.orElseThrow(() -> new BusinessException(MemberErrorCode.DEPARTMENT_NOT_ALLOWED));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,36 @@ public class Member {
private String studentId;
private String name;
private Department department;
// 로그인 provider가 주는 학적 상태 원문(재학·휴학·졸업 등). 로그인 전 이관 회원은 null이다
private String academicStatus;
private String email;
private String fcmToken;
private Role role;
private CouncilDepartment councilDepartment;

// 로그인으로 처음 가입하는 회원. 운영진 권한·학생회 부서는 가입 뒤 따로 부여한다
public static Member create(String studentId, String name, Department department, String academicStatus) {
return new Member(null, studentId, name, department, academicStatus, null, null, Role.STUDENT, null);
}

public static Member of(
Long id,
String studentId,
String name,
Department department,
String academicStatus,
String email,
String fcmToken,
Role role,
CouncilDepartment councilDepartment
) {
return new Member(id, studentId, name, department, email, fcmToken, role, councilDepartment);
return new Member(id, studentId, name, department, academicStatus, email, fcmToken, role, councilDepartment);
}

// 로그인할 때마다 provider의 최신 이름·학부·학적 상태로 갱신한다
public void updateProfile(String name, Department department, String academicStatus) {
this.name = name;
this.department = department;
this.academicStatus = academicStatus;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
@AllArgsConstructor
public enum MemberErrorCode implements ErrorCode {

MEMBER_NOT_FOUND(ErrorStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
MEMBER_NOT_FOUND(ErrorStatus.NOT_FOUND, "회원을 찾을 수 없습니다."),
DEPARTMENT_NOT_ALLOWED(ErrorStatus.FORBIDDEN, "소프트웨어융합대학 학생만 이용할 수 있습니다.");

private final int status;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package kr.ac.kookmin.stream.member.domain.member.domain;

/**
* 로그인 provider가 준 회원 프로필. major·academicStatus는 원문이며, 학부 변환은 {@link Department#fromMajor}가 한다.
*/
public record MemberProfileCommand(
String studentId,
String name,
String major,
String academicStatus
) {}
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
package kr.ac.kookmin.stream.member.domain.member.domain;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.experimental.Accessors;

@Getter
@Accessors(fluent = true)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public enum TermType {
PRIVACY_POLICY, // 개인정보 이용 동의
TERMS_OF_SERVICE // 서비스 이용 약관 동의

PRIVACY_POLICY(true), // 개인정보 이용 동의
TERMS_OF_SERVICE(true); // 서비스 이용 약관 동의

/** 가입 시 반드시 동의해야 하는 약관인지 여부 */
private final boolean required;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

public interface MemberRepository {
Optional<Member> findById(Long id);
Optional<Member> findByStudentId(String studentId);
List<Member> findAllByIds(List<Long> ids);
List<Long> searchIdsByKeyword(String keyword);
Member save(Member member);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package kr.ac.kookmin.stream.member.domain.member.repository;

import java.util.List;
import kr.ac.kookmin.stream.member.domain.member.domain.MemberTermAgreement;

public interface MemberTermAgreementRepository {
List<MemberTermAgreement> findAllByMemberId(Long memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
import java.util.List;
import java.util.Map;
import kr.ac.kookmin.stream.member.domain.member.domain.Member;
import kr.ac.kookmin.stream.member.domain.member.domain.MemberProfileCommand;

public interface MemberService {
Member getById(Long id);
List<Member> findAllByIds(List<Long> ids);
Map<Long, Member> getMapByIds(List<Long> ids);
List<Long> searchIdsByKeyword(String keyword);
Member updateProfile(Long id, MemberProfileCommand command);
Member registerOrUpdateByStudentId(MemberProfileCommand command);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package kr.ac.kookmin.stream.member.domain.member.service;

public interface MemberTermService {
boolean hasAgreedRequiredTerms(Long memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@
import java.util.function.Function;
import java.util.stream.Collectors;
import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.member.domain.member.domain.Department;
import kr.ac.kookmin.stream.member.domain.member.domain.Member;
import kr.ac.kookmin.stream.member.domain.member.domain.MemberErrorCode;
import kr.ac.kookmin.stream.member.domain.member.domain.MemberProfileCommand;
import kr.ac.kookmin.stream.member.domain.member.repository.MemberRepository;
import kr.ac.kookmin.stream.member.domain.member.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
Expand Down Expand Up @@ -40,4 +43,25 @@ public Map<Long, Member> getMapByIds(List<Long> ids) {
public List<Long> searchIdsByKeyword(String keyword) {
return memberRepository.searchIdsByKeyword(keyword);
}

@Override
@Transactional
public Member updateProfile(Long id, MemberProfileCommand command) {
Member member = getById(id);
member.updateProfile(command.name(), Department.fromMajor(command.major()), command.academicStatus());
return memberRepository.save(member);
}

@Override
@Transactional
public Member registerOrUpdateByStudentId(MemberProfileCommand command) {
Department department = Department.fromMajor(command.major());
Member member = memberRepository.findByStudentId(command.studentId())
.map(existing -> {
existing.updateProfile(command.name(), department, command.academicStatus());
return existing;
})
.orElseGet(() -> Member.create(command.studentId(), command.name(), department, command.academicStatus()));
return memberRepository.save(member);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package kr.ac.kookmin.stream.member.domain.member.service.impl;

import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import kr.ac.kookmin.stream.member.domain.member.domain.MemberTermAgreement;
import kr.ac.kookmin.stream.member.domain.member.domain.TermType;
import kr.ac.kookmin.stream.member.domain.member.repository.MemberTermAgreementRepository;
import kr.ac.kookmin.stream.member.domain.member.service.MemberTermService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
class MemberTermServiceImpl implements MemberTermService {

private final MemberTermAgreementRepository memberTermAgreementRepository;

@Override
public boolean hasAgreedRequiredTerms(Long memberId) {
Set<TermType> agreedTermTypes = memberTermAgreementRepository.findAllByMemberId(memberId).stream()
.filter(MemberTermAgreement::isAgreed)
.map(MemberTermAgreement::getTermType)
.collect(Collectors.toSet());
return Arrays.stream(TermType.values())
.filter(TermType::required)
.allMatch(agreedTermTypes::contains);
}
}
1 change: 1 addition & 0 deletions infrastructure/client/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ description = "외부 API 클라이언트 구현체"

dependencies {
implementation(project(":core:common"))
implementation(project(":core:domain:auth"))
implementation(project(":core:domain:internal"))

implementation(platform(libs.springBootDependenciesBom))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package kr.ac.kookmin.stream.client.oauth.kconnect;

import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;

/**
* {@link KConnectOAuthClient}가 쓰는 RestClient 빈 설정. 로그인 요청이 KConnect 응답을 기다리며 오래 묶이지 않도록 타임아웃을 건다.
*/
@Configuration
public class KConnectClientConfig {

private static final Logger log = LoggerFactory.getLogger(KConnectClientConfig.class);

@Bean
public RestClient kconnectRestClient(KConnectProperties properties) {
// 로컬처럼 KConnect 설정 없이도 기동은 되게 두고, 빠진 설정을 알려만 준다. 로그인하면 500이 난다
List<String> missingSettings = properties.missingSettings();
if (!missingSettings.isEmpty()) {
log.warn("KConnect 설정이 비어 있어 KConnect 로그인을 쓸 수 없습니다: {}", missingSettings);
}

SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(properties.connectTimeout());
requestFactory.setReadTimeout(properties.readTimeout());

return RestClient.builder()
.baseUrl(properties.baseUrl())
.requestFactory(requestFactory)
.build();
}
}
Loading