예약 관리 시스템 - Spring + Vue

예약 관리 시스템 (Spring Boot 3 + Vue 3) - 예약 관리(관리자)

그랜파 개발자 2025. 5. 27. 18:41

관리자 모드

1. 관리자 페이지

1. 관리자는 DB에 수동 등록

  • 회원 등록, DB에서 수동으로 Role을 ADMIN으로 변경

2. 예약 조회, 관리 - 관리자 기능

  • 날짜 선택, 예약 조회, 예약 승인/거부
  • 예약 승인/거부는 관리자만 할 수 있음
  • 관리자 기능은 관리자만 사용가능하도록 제한

3. 백엔드

  • 컨트롤러에서 역할에 따라 접근 제어하기
    • 예약 상태 변경 : 예약 승인 (APPROVED), 거절 (REJECTED)
  • Jwt Token 생성할 때 이메일 뿐 아니라 역할(Role)도 클레임에 포함
  • Jwt Token에서 역할 추출 메서드 추가
    • 로그인할 때 관리자, 사용자 구분 가능
  • SecurityFilterChain에 관리자 전용 경로 설정
    • /api/admin/** 경로는 ADMIN만 접근 가능하게 설정

 

 

 

- 프론트엔드 - 

AdminReservations.vue

<!-- src/views/AdminReservations.vue -->
 <template>
  <v-container>
    <!-- 날짜 선택 -->
    <v-menu
      v-model="menu"
      :close-on-content-click="false"
      transition="scale-transition"
      offset-y
      max-width="290px"
      min-width="290px"
    >
      <template #activator="{ props }">
        <v-text-field
          v-model="selectedDate"
          label="예약 날짜 선택"
          prepend-icon="mdi-calendar"
          readonly
          v-bind="props"
        ></v-text-field>
      </template>

      <v-date-picker
        v-model="selectedDate"
        @update:modelValue="onDateChange"
      />
    </v-menu>

    <!-- 예약 카드 -->
    <v-card class="mb-4" v-for="reservation in reservations" :key="reservation.id">
      <v-card-title>
        예약 ID: {{ reservation.id }} - {{ reservation.date }} - 상태: {{ statusText(reservation.status) }}
      </v-card-title>
      <v-card-text>
        메모: {{ reservation.memo }}
        <br />
        시간 슬롯:
        <ul>
          <li v-for="slot in reservation.timeSlots" :key="slot.id">{{ slot.timeSlot }}</li>
        </ul>
      </v-card-text>
      <v-card-actions>
        <v-btn
          color="green"
          @click="updateStatus(reservation.id, 'APPROVED')"
          :disabled="reservation.status !== 'PENDING'"
        >
          승인
        </v-btn>
        <v-btn
          color="red"
          @click="updateStatus(reservation.id, 'REJECTED')"
          :disabled="reservation.status !== 'PENDING'"
        >
          거부
        </v-btn>
      </v-card-actions>
    </v-card>
  </v-container>
</template>

<script setup>
import { ref, watch } from 'vue'
import { useReservationStore } from '@/stores/reservationStore'

const reservationStore = useReservationStore()

const reservations = ref([])
const selectedDate = ref('')
const menu = ref(false)

const statusText = (status) => {
  const map = {
    PENDING: '대기 중',
    APPROVED: '승인',
    REJECTED: '거부',
    CANCELLED: '취소됨'
  };
  return map[status] || '알 수 없음';
};

const fetchAllReservations = async () => {
  try {
    if (!selectedDate.value) {
      reservations.value = []
      return
    }
    // 날짜 필터를 서버 API 호출에 전달
    const res = await reservationStore.fetchReservations(selectedDate.value)
    reservations.value = res
  } catch (err) {
    console.error('예약 목록 조회 실패', err)
  }
}

// 날짜가 바뀔 때마다 예약 목록 다시 조회
const formatDateTime = (value) => {
  const date = new Date(value)
  const year = date.getFullYear()
  const month = String(date.getMonth() + 1).padStart(2, '0')
  const day = String(date.getDate()).padStart(2, '0')
  return `${year}-${month}-${day}`
}

const onDateChange = (date) => {
  selectedDate.value = formatDateTime(date)
  menu.value = false // 달력 닫기
  fetchAllReservations()
}

// ✅ 예약 상태 업데이트
const updateStatus = async (reservationId, newStatus) => {
  try {
    await reservationStore.updateStatus(reservationId, newStatus)
    await fetchAllReservations()  // 변경 후 목록 다시 불러오기
  } catch (err) {
    console.error('예약 상태 변경 실패', err)
    alert('예약 상태 변경 중 오류가 발생했습니다.')
  }
}
</script>

 

stores - reservationStore.js

// src/stores/reservationStore.js
import { defineStore } from 'pinia'
import axios from 'axios'

const API_URL = 'http://localhost:8080/api/auth'
const API_ADMIN_URL = 'http://localhost:8080/api/admin'

export const useReservationStore = defineStore('reservation', () => {
  // 예약 제출 함수
  const submitReservation = async (formData) => {
    const token = localStorage.getItem('token') // 로그인 후 저장된 JWT
    try {
      const response = await axios.post(
        `${API_URL}/reservation`,
        formData,
        {
          headers: {
            Authorization: `Bearer ${token}`,
          },
        }
      )
      return response.data // 호출한 쪽에서 alert 등 처리 가능
    } catch (err) {
      // 에러를 그대로 던져서 컴포넌트에서 처리하도록
      throw err
    }
  }

  const fetchReservations = async (date) => {
    try {
      const token = localStorage.getItem('token')
      if (!token) throw new Error('인증 토큰이 없습니다.')
      const params = date ? { date } : {}
      const response = await axios.get(`${API_ADMIN_URL}/reservations`, {
        headers: {
          Authorization: `Bearer ${token}`,
        },
        params,
      })

      return response.data
    } catch (err) {
      console.error('예약 목록 조회 실패:', err)
      throw err
    }
  }

  const updateStatus = async (id, status) => {
    try {
      const token = localStorage.getItem('token')
      const endpoint =
        status === 'APPROVED'
          ? `${API_ADMIN_URL}/reservation/${id}/approve`
          : `${API_ADMIN_URL}/reservation/${id}/reject`

      await axios.post(endpoint, null, {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      })

      await fetchReservations() // 상태 업데이트 후 다시 불러오기
    } catch (err) {
      console.error('상태 변경 실패', err)
    }
  }

  return {
    submitReservation,
    fetchReservations,
    updateStatus,
  }
})

 

- 백엔드 -

JwtUtil.java

package com.example.reservation.util;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Date;
import java.util.List;

@Component
public class JwtUtil {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private long expiration;

    // 토큰 생성 시 역할 포함
    public String generateToken(String email, String role) {
        Date now = new Date();
        Date expiry = new Date(now.getTime() + expiration);
        Key key = Keys.hmacShaKeyFor(secret.getBytes());

        return Jwts.builder()
                .setSubject(email)
                .claim("role", role)   // 역할 추가
                .setIssuedAt(now)
                .setExpiration(expiry)
                .signWith(key)
                .compact();
    }

    // 토큰에서 사용자 이메일 추출
    public String extractUsername(String token) {
        Key key = Keys.hmacShaKeyFor(secret.getBytes());
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    // 토큰에서 역할 목록 추출
    @SuppressWarnings("unchecked")
    public List<String> extractRoles(String token) {
        Key key = Keys.hmacShaKeyFor(secret.getBytes());
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();

        Object roles = claims.get("role");
        if (roles instanceof List<?>) {
            return (List<String>) roles;
        }
        return List.of();
    }

    // 토큰 유효성 검사
    public boolean isTokenValid(String token) {
        try {
            Key key = Keys.hmacShaKeyFor(secret.getBytes());
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (JwtException e) {
            return false;
        }
    }
}

 

SecurityConfig.java

package com.example.reservation.config;

import lombok.RequiredArgsConstructor;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.*;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.*;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.*;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;

import java.util.List;

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);

    private final JwtFilter jwtFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(csrf -> csrf.disable())
            .cors(cors -> cors.configurationSource(request -> {
                CorsConfiguration corsConfig = new CorsConfiguration();
                corsConfig.setAllowedOrigins(List.of("http://localhost:5173"));
                corsConfig.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
                corsConfig.setAllowedHeaders(List.of("*"));
                corsConfig.setAllowCredentials(true);
                return corsConfig;
            }))
            .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
        		.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()  // OPTIONS 요청 허용
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            );

        http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }


    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }


    @Bean
    public AuthenticationManager authenticationManager(
        AuthenticationConfiguration config
    ) throws Exception {
        return config.getAuthenticationManager();
    }
}

AuthController.java

package com.example.reservation.controller;

import com.example.reservation.dto.*;
import com.example.reservation.model.User;
import com.example.reservation.repository.UserRepository;
import com.example.reservation.util.JwtUtil;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;

@CrossOrigin(origins = "http://localhost:5173")
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtUtil jwtUtil;
    
    @PostMapping("/register")
    public ResponseEntity<ApiResponse> register(@RequestBody @Valid RegisterRequest request) {
        if (userRepository.findByEmail(request.getEmail()).isPresent()) {
            return ResponseEntity.status(HttpStatus.CONFLICT)
                .body(new ApiResponse(false, "Email already registered"));
        }

        User user = new User();
        user.setEmail(request.getEmail());
        user.setPassword(passwordEncoder.encode(request.getPassword()));
        user.setName(request.getName());
        user.setRole("USER");

        userRepository.save(user);
        return ResponseEntity.ok(new ApiResponse(true, "User registered successfully"));
    }
    
    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(@RequestBody AuthRequest request) {
        User user = userRepository.findByEmail(request.getEmail())
                .orElseThrow(() -> new UsernameNotFoundException("Invalid email or password"));

        if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
            throw new BadCredentialsException("Invalid email or password");
        }

        // role 포함해서 토큰 생성
        String token = jwtUtil.generateToken(user.getEmail(), user.getRole());

        AuthResponse response = new AuthResponse(token, user.getName(), user.getRole());
        return ResponseEntity.ok(response);
    }
}

 

ReservationController.java

package com.example.reservation.controller;

import com.example.reservation.dto.ReservationRequest;
import com.example.reservation.dto.ReservationResponse;
import com.example.reservation.model.Reservation;
import com.example.reservation.model.ReservationStatus;
import com.example.reservation.service.ReservationService;
import com.example.reservation.util.JwtUtil;
import lombok.RequiredArgsConstructor;

import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/")
@CrossOrigin(origins = "http://localhost:5173")
@RequiredArgsConstructor
public class ReservationController {

	private static final Logger logger = LoggerFactory.getLogger(ReservationController.class);

    private final ReservationService reservationService;
    private final JwtUtil jwtUtil;

    @PostMapping("auth/reservation")
    public ReservationResponse create(
            @RequestBody ReservationRequest request,
            @RequestHeader("Authorization") String authHeader
    ) {
        String token = authHeader.replace("Bearer ", "");
        String email = jwtUtil.extractUsername(token);
        return reservationService.createReservation(email, request);
    }
    
    /**
     * ✅ 관리자: 전체 예약 목록 조회
     */
    @GetMapping("admin/reservations")
    @PreAuthorize("hasRole('ADMIN')")
    public List<Reservation> getAllReservations(
        @RequestParam(required = false) String date
    ) {
        if (date != null && !date.isEmpty()) {
            return reservationService.getReservationsByDate(date);
        }
        return reservationService.getAllReservations();
    }
    
    
    @PreAuthorize("hasRole('ADMIN')")
    @PostMapping("admin/reservation/{id}/approve")
    public ResponseEntity<?> approveReservation(@PathVariable Long id) {
    	logger.info("==== approveReservation : " + id);
        reservationService.updateReservationStatus(id, ReservationStatus.APPROVED);
        return ResponseEntity.ok("Reservation approved");
    }

    @PreAuthorize("hasRole('ADMIN')")
    @PostMapping("admin/reservation/{id}/reject")
    public ResponseEntity<?> rejectReservation(@PathVariable Long id) {
    	logger.info("==== rejectReservation : " + id);
        reservationService.updateReservationStatus(id, ReservationStatus.REJECTED);
        return ResponseEntity.ok("Reservation rejected");
    }
}

 

ReservationRepository.java

package com.example.reservation.repository;

import com.example.reservation.model.Reservation;
import com.example.reservation.model.User;

import java.time.LocalDate;
import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface ReservationRepository extends JpaRepository<Reservation, Long> {
    // JpaRepository를 상속받으면 save() 등 기본 CRUD 메서드 사용 가능
	// 특정 사용자에 대한 예약 목록 조회
    List<Reservation> findByUser(User user);
    
    // 날짜 기준 예약 목록 조회
    List<Reservation> findByDate(LocalDate date);
}

 

ReservationService.java

package com.example.reservation.service;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.stereotype.Service;

import com.example.reservation.dto.ReservationRequest;
import com.example.reservation.dto.ReservationResponse;
import com.example.reservation.model.Reservation;
import com.example.reservation.model.ReservationStatus;
import com.example.reservation.model.ReservationTimeSlot;
import com.example.reservation.model.User;
import com.example.reservation.repository.ReservationRepository;
import com.example.reservation.repository.UserRepository;

import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class ReservationService {

    private final ReservationRepository reservationRepository;
    private final UserRepository userRepository;

    /**
     * 사용자: 예약 생성
     */
    @Transactional
    public ReservationResponse createReservation(String email, ReservationRequest request) {
        User user = userRepository.findByEmail(email).orElseThrow();

        Reservation reservation = new Reservation();
        reservation.setDate(LocalDate.parse(request.getDate()));
        reservation.setUser(user);
        reservation.setMemo(request.getMemo());
        reservation.setStatus(ReservationStatus.PENDING);
        reservation.setCreatedAt(LocalDateTime.now());

        List<ReservationTimeSlot> slotEntities = request.getTimeSlots().stream()
            .map(ts -> {
                ReservationTimeSlot slot = new ReservationTimeSlot();
                slot.setTimeSlot(ts);
                slot.setReservation(reservation);  // 필수
                return slot;
            }).collect(Collectors.toList());

        reservation.setTimeSlots(slotEntities);

        Reservation saved = reservationRepository.save(reservation);

        return new ReservationResponse(
                saved.getId(),
                saved.getDate().toString(),
                saved.getTimeSlots().stream().map(ReservationTimeSlot::getTimeSlot).toList(),
                saved.getMemo(),
                saved.getStatus().name(),
                saved.getCreatedAt().toString()
        );
    }
    
    /**
     * 관리자: 예약 상태 변경 (APPROVED, REJECTED, CANCELLED 등)
     */
    @Transactional
    public Reservation updateReservationStatus(Long reservationId, ReservationStatus status) {
        Reservation reservation = reservationRepository.findById(reservationId)
                .orElseThrow(() -> new RuntimeException("Reservation not found"));

        reservation.setStatus(status);
        return reservationRepository.save(reservation);
    }

    /**
     * 관리자: 전체 예약 목록 조회
     */
    public List<Reservation> getAllReservations() {
        return reservationRepository.findAll();
    }
    
    public List<Reservation> getReservationsByDate(String date) {
        // date 형식에 맞게 파싱 후 DB 쿼리 (예: '2025-05-27')
    	LocalDate localDate = LocalDate.parse(date);  // 문자열 → LocalDate 변환
        return reservationRepository.findByDate(localDate);
    }

    /**
     * 사용자: 내 예약 목록 조회
     */
    public List<Reservation> getReservationsByUser(String email) {
    	User user = userRepository.findByEmail(email).orElseThrow();
        return reservationRepository.findByUser(user);
    }
}