学内食堂予約システムのSpringBoot+Vue.js実装と設計解説

学内食堂の混雑緩和と効率的な食事管理を目的とした予約システムを、Spring BootとVue.jsを用いて構築しました。本システムはモダンなフルスタックアーキテクチャに基づき、柔軟な拡張性と高い保守性を実現しています。

バックエンド:Spring BootによるAPI設計

Spring Bootは依存性の自動設定と組み込みサーバーにより、最小限の設定で高機能なWebアプリケーションを構築可能です。以下は認証APIの再設計例です:

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    @Autowired
    private AuthService authService;

    @PostMapping("/signin")
    public ResponseEntity<AuthResponse> authenticate(@RequestBody LoginRequest req) {
        try {
            AuthToken token = authService.createSession(req.getEmail(), req.getSecret());
            return ResponseEntity.ok(new AuthResponse(token.getValue(), token.getExpiry()));
        } catch (InvalidCredentialsException e) {
            return ResponseEntity.status(401).body(new AuthResponse("認証失敗", null));
        }
    }
}

@Service
public class AuthService {
    
    public AuthToken createSession(String email, String secret) {
        UserAccount account = userRepository.findByEmail(email)
            .orElseThrow(() -> new InvalidCredentialsException());

        if (!passwordEncoder.matches(secret, account.getPasswordHash())) {
            throw new InvalidCredentialsException();
        }

        String tokenValue = UUID.randomUUID().toString().replace("-", "");
        LocalDateTime expiry = LocalDateTime.now().plusHours(2);

        SessionToken session = new SessionToken();
        session.setOwner(account.getId());
        session.setTokenValue(tokenValue);
        session.setExpiresAt(expiry);
        session.setLastUsed(LocalDateTime.now());

        tokenRepository.save(session);

        return new AuthToken(tokenValue, expiry);
    }
}

フロントエンド:Vue 3 Composition APIによるUI構築

Vue 3のComposition APIを活用し、リアクティブな予約インターフェースを実装します:

<script setup>
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';

const mealSlots = ref([]);
const selectedDate = ref(new Date().toISOString().split('T')[0]);
const userToken = ref(localStorage.getItem('auth_token'));

const fetchAvailableSlots = async () => {
  const res = await fetch(`/api/reservations?date=${selectedDate.value}`, {
    headers: { 'Authorization': `Bearer ${userToken.value}` }
  });
  mealSlots.value = await res.json();
};

const reserveSlot = async (slotId) => {
  await fetch('/api/reservations', {
    method: 'POST',
    headers: { 
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${userToken.value}`
    },
    body: JSON.stringify({ slotId, date: selectedDate.value })
  });
  fetchAvailableSlots();
};

onMounted(() => {
  if (userToken.value) fetchAvailableSlots();
});
</script>

<template>
  <div class="reservation-container">
    <input v-model="selectedDate" type="date" @change="fetchAvailableSlots" />
    <div v-for="slot in mealSlots" :key="slot.id" class="meal-slot">
      <span>{{ slot.timeRange }} - {{ slot.menuName }}</span>
      <button @click="reserveSlot(slot.id)" :disabled="!slot.available">
        {{ slot.available ? '予約' : '満席' }}
      </button>
    </div>
  </div>
</template>

データベース設計:予約管理テーブル

MySQLを使用した主要テーブル構造:

CREATE TABLE dining_slots (
  slot_id BIGINT PRIMARY KEY AUTO_INCREMENT,
  meal_type ENUM('BREAKFAST', 'LUNCH', 'DINNER') NOT NULL,
  start_time TIME NOT NULL,
  end_time TIME NOT NULL,
  capacity INT DEFAULT 50,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE reservations (
  reservation_id BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id BIGINT NOT NULL,
  slot_id BIGINT NOT NULL,
  reservation_date DATE NOT NULL,
  status ENUM('CONFIRMED', 'CANCELLED', 'NO_SHOW') DEFAULT 'CONFIRMED',
  reserved_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(user_id),
  FOREIGN KEY (slot_id) REFERENCES dining_slots(slot_id),
  UNIQUE KEY unique_user_slot_date (user_id, slot_id, reservation_date)
);

セキュリティ:JWTベースの認証ミドルウェア

JWTトークン検証用のインターセプター:

@Component
public class JwtInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String authHeader = request.getHeader("Authorization");
        
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            sendUnauthorized(response);
            return false;
        }

        String token = authHeader.substring(7);
        try {
            Claims claims = Jwts.parser()
                .setSigningKey(jwtSecret)
                .parseClaimsJws(token)
                .getBody();

            if (isTokenExpired(claims)) {
                sendUnauthorized(response);
                return false;
            }

            // ユーザーコンテキストを設定
            SecurityContextHolder.getContext().setAuthentication(
                new UsernamePasswordAuthenticationToken(
                    claims.getSubject(), 
                    null, 
                    extractAuthorities(claims)
                )
            );
            return true;

        } catch (Exception e) {
            sendUnauthorized(response);
            return false;
        }
    }

    private void sendUnauthorized(HttpServletResponse response) throws IOException {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType("application/json");
        response.getWriter().write("{\"error\":\"認証が必要です\"}");
    }
}

テスト戦略:統合テストケース

予約機能の主要なテストシナリオ:

テスト項目 入力条件 期待結果 実際の結果
空き枠予約 有効なトークン、空いている時間帯 予約成功、ステータス201 一致
重複予約 同一ユーザーが同日同じ時間帯 エラー409、重複メッセージ 一致
容量超過 満席の時間帯への予約 エラー400、満席メッセージ 一致
無効トークン 期限切れまたは不正なトークン エラー401、認証失敗 一致

タグ: SpringBoot vue.js JWT MySQL rest-api

5月19日 09:35 投稿