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
133 changes: 133 additions & 0 deletions .github/workflows/ff-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
name: FF-Only Merge to Master

on:
pull_request:
types: [labeled]
check_suite:
types: [completed]

jobs:
ff-merge:
if: |
(github.event_name == 'pull_request' && github.event.label.name == 'ready-to-merge') ||
(github.event_name == 'check_suite' && github.event.check_suite.conclusion == 'success')
runs-on: ubuntu-latest

steps:
- name: PR 조회 및 조건 검증
id: validate
uses: actions/github-script@v7
with:
script: |
let prNumber, headSha;

if (context.eventName === 'pull_request') {
const pr = context.payload.pull_request;
if (pr.base.ref !== 'master' || pr.head.ref !== 'develop' || pr.state !== 'open') {
core.setOutput('ready', 'false');
return;
}

// 레이블을 부착한 주체의 저장소 권한 확인
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.sender.login,
});

if (!['admin', 'write'].includes(perm.permission)) {
core.setOutput('ready', 'false');
return;
}

prNumber = pr.number;
headSha = pr.head.sha;
} else {
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
base: 'master',
head: `${context.repo.owner}:develop`,
});

if (prs.length === 0) {
core.setOutput('ready', 'false');
return;
}

prNumber = prs[0].number;
headSha = prs[0].head.sha;

if (context.payload.check_suite.head_sha !== headSha) {
core.setOutput('ready', 'false');
return;
}
}

// ready-to-merge 레이블 확인
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});

const hasLabel = pr.labels.some(l => l.name === 'ready-to-merge');
if (!hasLabel) {
core.setOutput('ready', 'false');
return;
}

// CI 상태 확인
const { data: { check_runs } } = await github.rest.checks.listForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: headSha,
per_page: 100,
});

const ciRuns = check_runs.filter(r => r.name !== context.job);
const allPassed = ciRuns.length > 0 && ciRuns.every(r =>
r.status === 'completed' &&
['success', 'skipped', 'neutral'].includes(r.conclusion)
);

if (!allPassed) {
core.setOutput('ready', 'false');
return;
}

core.setOutput('ready', 'true');
core.setOutput('head_sha', headSha);

- name: Checkout
if: steps.validate.outputs.ready == 'true'
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
fetch-depth: 0

- name: Git 설정
if: steps.validate.outputs.ready == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

- name: develop 변경 여부 검증
if: steps.validate.outputs.ready == 'true'
run: |
APPROVED_SHA="${{ steps.validate.outputs.head_sha }}"
CURRENT_SHA=$(git rev-parse origin/develop)
if [ "$APPROVED_SHA" != "$CURRENT_SHA" ]; then
echo "레이블 부착 이후 develop이 변경되었습니다."
echo " 레이블 시점 SHA: $APPROVED_SHA"
echo " 현재 SHA: $CURRENT_SHA"
exit 1
fi

- name: FF-Only merge develop → master
if: steps.validate.outputs.ready == 'true'
run: |
git checkout master
git merge --ff-only ${{ steps.validate.outputs.head_sha }}
git push origin master
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.example.solidconnection.admin.university.controller;

import com.example.solidconnection.admin.university.dto.AdminHomeUniversityCreateRequest;
import com.example.solidconnection.admin.university.dto.AdminHomeUniversityResponse;
import com.example.solidconnection.admin.university.dto.AdminHomeUniversityUpdateRequest;
import com.example.solidconnection.admin.university.service.AdminHomeUniversityService;
import jakarta.validation.Valid;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RequestMapping("/admin/home-universities")
@RestController
public class AdminHomeUniversityController {

private final AdminHomeUniversityService adminHomeUniversityService;

@GetMapping
public ResponseEntity<List<AdminHomeUniversityResponse>> getHomeUniversities() {
return ResponseEntity.ok(adminHomeUniversityService.getAllHomeUniversities());
}

@GetMapping("/{home-university-id}")
public ResponseEntity<AdminHomeUniversityResponse> getHomeUniversity(
@PathVariable("home-university-id") Long homeUniversityId
) {
return ResponseEntity.ok(adminHomeUniversityService.getHomeUniversity(homeUniversityId));
}

@PostMapping
public ResponseEntity<AdminHomeUniversityResponse> createHomeUniversity(
@Valid @RequestBody AdminHomeUniversityCreateRequest request
) {
return ResponseEntity.ok(adminHomeUniversityService.createHomeUniversity(request));
}

@PutMapping("/{home-university-id}")
public ResponseEntity<AdminHomeUniversityResponse> updateHomeUniversity(
@PathVariable("home-university-id") Long homeUniversityId,
@Valid @RequestBody AdminHomeUniversityUpdateRequest request
) {
return ResponseEntity.ok(adminHomeUniversityService.updateHomeUniversity(homeUniversityId, request));
}

@DeleteMapping("/{home-university-id}")
public ResponseEntity<Void> deleteHomeUniversity(
@PathVariable("home-university-id") Long homeUniversityId
) {
adminHomeUniversityService.deleteHomeUniversity(homeUniversityId);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.solidconnection.admin.university.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record AdminHomeUniversityCreateRequest(
@NotBlank(message = "협정 대학명은 필수입니다")
@Size(max = 100, message = "협정 대학명은 100자 이하여야 합니다")
String name
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.example.solidconnection.admin.university.dto;

import com.example.solidconnection.university.domain.HomeUniversity;

public record AdminHomeUniversityResponse(
long id,
String name
) {

public static AdminHomeUniversityResponse from(HomeUniversity homeUniversity) {
return new AdminHomeUniversityResponse(
homeUniversity.getId(),
homeUniversity.getName()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.solidconnection.admin.university.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record AdminHomeUniversityUpdateRequest(
@NotBlank(message = "협정 대학명은 필수입니다")
@Size(max = 100, message = "협정 대학명은 100자 이하여야 합니다")
String name
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.example.solidconnection.admin.university.service;

import static com.example.solidconnection.common.exception.ErrorCode.HOME_UNIVERSITY_ALREADY_EXISTS;
import static com.example.solidconnection.common.exception.ErrorCode.HOME_UNIVERSITY_HAS_REFERENCES;
import static com.example.solidconnection.common.exception.ErrorCode.HOME_UNIVERSITY_NOT_FOUND;

import com.example.solidconnection.admin.university.dto.AdminHomeUniversityCreateRequest;
import com.example.solidconnection.admin.university.dto.AdminHomeUniversityResponse;
import com.example.solidconnection.admin.university.dto.AdminHomeUniversityUpdateRequest;
import com.example.solidconnection.cache.annotation.DefaultCacheOut;
import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.siteuser.repository.SiteUserRepository;
import com.example.solidconnection.university.domain.HomeUniversity;
import com.example.solidconnection.university.repository.HomeUniversityRepository;
import com.example.solidconnection.university.repository.UnivApplyInfoRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class AdminHomeUniversityService {

private final HomeUniversityRepository homeUniversityRepository;
private final UnivApplyInfoRepository univApplyInfoRepository;
private final SiteUserRepository siteUserRepository;

@Transactional(readOnly = true)
public List<AdminHomeUniversityResponse> getAllHomeUniversities() {
return homeUniversityRepository.findAll().stream()
.map(AdminHomeUniversityResponse::from)
.toList();
}

@Transactional(readOnly = true)
public AdminHomeUniversityResponse getHomeUniversity(Long id) {
HomeUniversity homeUniversity = homeUniversityRepository.findById(id)
.orElseThrow(() -> new CustomException(HOME_UNIVERSITY_NOT_FOUND));
return AdminHomeUniversityResponse.from(homeUniversity);
}

@Transactional
@DefaultCacheOut(
key = {"univApplyInfoTextSearch", "university:recommend:general"},
cacheManager = "customCacheManager",
prefix = true
)
public AdminHomeUniversityResponse createHomeUniversity(AdminHomeUniversityCreateRequest request) {
validateNameNotExists(request.name());
HomeUniversity homeUniversity = new HomeUniversity(null, request.name());
return AdminHomeUniversityResponse.from(homeUniversityRepository.save(homeUniversity));
}

private void validateNameNotExists(String name) {
homeUniversityRepository.findByName(name)
.ifPresent(existing -> {
throw new CustomException(HOME_UNIVERSITY_ALREADY_EXISTS);
});
}

@Transactional
@DefaultCacheOut(
key = {"univApplyInfoTextSearch", "university:recommend:general"},
cacheManager = "customCacheManager",
prefix = true
)
public AdminHomeUniversityResponse updateHomeUniversity(Long id, AdminHomeUniversityUpdateRequest request) {
HomeUniversity homeUniversity = homeUniversityRepository.findById(id)
.orElseThrow(() -> new CustomException(HOME_UNIVERSITY_NOT_FOUND));
validateNameNotDuplicated(request.name(), id);
homeUniversity.update(request.name());
return AdminHomeUniversityResponse.from(homeUniversity);
}

private void validateNameNotDuplicated(String name, Long excludeId) {
homeUniversityRepository.findByName(name)
.ifPresent(existing -> {
if (!existing.getId().equals(excludeId)) {
throw new CustomException(HOME_UNIVERSITY_ALREADY_EXISTS);
}
});
}

@Transactional
@DefaultCacheOut(
key = {"univApplyInfoTextSearch", "university:recommend:general"},
cacheManager = "customCacheManager",
prefix = true
)
public void deleteHomeUniversity(Long id) {
HomeUniversity homeUniversity = homeUniversityRepository.findById(id)
.orElseThrow(() -> new CustomException(HOME_UNIVERSITY_NOT_FOUND));
validateNoReferences(id);
homeUniversityRepository.delete(homeUniversity);
}

private void validateNoReferences(Long id) {
if (univApplyInfoRepository.existsByHomeUniversityId(id)
|| siteUserRepository.existsByHomeUniversityId(id)) {
throw new CustomException(HOME_UNIVERSITY_HAS_REFERENCES);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ public enum ErrorCode {
COUNTRY_ALREADY_EXISTS(HttpStatus.CONFLICT.value(), "이미 존재하는 국가입니다."),
HOST_UNIVERSITY_ALREADY_EXISTS(HttpStatus.CONFLICT.value(), "이미 존재하는 파견 대학입니다."),
HOST_UNIVERSITY_HAS_REFERENCES(HttpStatus.CONFLICT.value(), "해당 파견 대학을 참조하는 대학 지원 정보가 존재합니다."),
HOME_UNIVERSITY_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "협정 대학교를 찾을 수 없습니다."),
HOME_UNIVERSITY_ALREADY_EXISTS(HttpStatus.CONFLICT.value(), "이미 존재하는 협정 대학입니다."),
HOME_UNIVERSITY_HAS_REFERENCES(HttpStatus.CONFLICT.value(), "해당 협정 대학을 참조하는 데이터가 존재합니다."),
COUNTRY_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "국가를 찾을 수 없습니다."),
COUNTRY_NOT_FOUND_BY_KOREAN_NAME(HttpStatus.NOT_FOUND.value(), "이름에 해당하는 국가를 찾을 수 없습니다."),
GPA_SCORE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 학점입니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public interface SiteUserRepository extends JpaRepository<SiteUser, Long>, SiteU

List<SiteUser> findAllByIdIn(List<Long> ids);

boolean existsByHomeUniversityId(Long homeUniversityId);

@Modifying
@Query("UPDATE SiteUser u SET u.userStatus = :status WHERE u.id IN :userIds")
void bulkUpdateUserStatus(@Param("userIds") List<Long> userIds, @Param("status") UserStatus status);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,8 @@ public class HomeUniversity extends BaseEntity {

@Column(name = "name", nullable = false, unique = true, length = 100)
private String name;

public void update(String name) {
this.name = name;
}
}
Loading
Loading