Spring Boot と Vue.js を活用した中古車 EC プラットフォームの設計と実装

概要

紙ベースや Excel による在庫管理では情報の鮮度が落ち、購入者と販売者の双方に不満が生じやすい。本稿では、リアルタイム在庫更新とチャット機能を備えた中古車 EC プラットフォームを Spring Boot(バックエンド)と Vue.js(フロントエンド)で構築する方法を解説する。システムは MySQL 8.0 をデータストアに採用し、Docker Compose による環境構築を前提とする。

システムアーキテクチャ

┌──────────────┐      ┌──────────────┐
│   Vue.js     │──────│ Spring Boot  │
│ (Nginx 80)   │REST  │ (Tomcat 8080)│
└──────────────┘      └──────┬───────┘
                             │
                    ┌────────┴────────┐
                    │   MySQL 8.0     │
                    │   (3306)        │
                    └─────────────────┘

主要機能一覧

  • 車両在庫 CRUD(画像アップロード含む)
  • ブランド・モデルマスタ管理
  • ユーザー(購入者/販売者/管理者)ロール管理
  • お気に入り・閲覧履歴
  • 掲示板(スレッド形式)
  • リアルタイム通知(WebSocket)

バックエンド実装

エンティティ例:Car

@Entity
@Table(name = "cars")
public class Car {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    private BigDecimal price;

    private Integer year;

    private String fuel;

    private Integer mileage;

    @ElementCollection
    @CollectionTable(name = "car_images", joinColumns = @JoinColumn(name = "car_id"))
    @Column(name = "image_url")
    private Set<String> images = new HashSet<>();

    @Enumerated(EnumType.STRING)
    private CarStatus status = CarStatus.AVAILABLE;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "seller_id")
    private User seller;

    @CreationTimestamp
    private LocalDateTime createdAt;

    // getters / setters ...
}

REST コントローラ例:車両管理

@RestController
@RequestMapping("/api/cars")
@RequiredArgsConstructor
public class CarController {

    private final CarService carService;

    @GetMapping
    public Page<CarDto> list(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(required = false) String brand,
            @RequestParam(required = false) BigDecimal minPrice,
            @RequestParam(required = false) BigDecimal maxPrice) {

        return carService.search(page, size, brand, minPrice, maxPrice);
    }

    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<CarDto> create(
            @RequestPart("car") @Valid CarCreateRequest req,
            @RequestPart("files") List<MultipartFile> files) throws IOException {

        CarDto saved = carService.create(req, files);
        URI location = ServletUriComponentsBuilder
                .fromCurrentRequest().path("/{id}")
                .buildAndExpand(saved.getId()).toUri();
        return ResponseEntity.created(location).body(saved);
    }

    @PutMapping("/{id}")
    public CarDto update(@PathVariable Long id,
                         @RequestBody @Valid CarUpdateRequest req) {
        return carService.update(id, req);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        carService.delete(id);
    }
}

画像アップロードサービス

@Service
public class ImageStorageService {

    @Value("${app.upload.path}")
    private Path uploadPath;

    @PostConstruct
    public void init() throws IOException {
        if (!Files.exists(uploadPath)) {
            Files.createDirectories(uploadPath);
        }
    }

    public String store(MultipartFile file) {
        String ext = FilenameUtils.getExtension(file.getOriginalFilename());
        String fileName = UUID.randomUUID() + "." + ext;
        try {
            Files.copy(file.getInputStream(), uploadPath.resolve(fileName),
                       StandardCopyOption.REPLACE_EXISTING);
        } catch (IOException e) {
            throw new StorageException("ファイル保存に失敗", e);
        }
        return "/uploads/" + fileName;
    }
}

フロントエンド実装

Vue コンポーネント:車両一覧

<template>
  <div class="car-list">
    <Filters @change="handleFilter" />
    <div v-if="loading">Loading...</div>
    <div v-else class="grid">
      <CarCard v-for="car in cars" :key="car.id" :car="car" />
    </div>
    <Pagination :page="page" :total="total" @change="handlePage" />
  </div>
</template>

<script setup>
import { ref, watchEffect } from 'vue'
import { getCars } from '@/api/car'

const cars = ref([])
const page = ref(0)
const size = 20
const total = ref(0)
const loading = ref(false)
const filters = ref({})

const fetch = async () => {
  loading.value = true
  const res = await getCars({ page: page.value, size, ...filters.value })
  cars.value = res.content
  total.value = res.totalElements
  loading.value = false
}

const handleFilter = (newFilters) => {
  filters.value = newFilters
  page.value = 0
}

const handlePage = (newPage) => {
  page.value = newPage
}

watchEffect(fetch)
</script>

認証・認可

Spring Security + JWT でステートレス認証を実現。Vue 側では Axios インターセプターでトークンを自動付与する。

@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtFilter jwtFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeHttpRequests()
                .requestMatchers("/api/auth/**", "/uploads/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/cars/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            .and()
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }
}

WebSocket 通知

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS();
    }
}

テスト戦略

  • 単体テスト:JUnit 5 + Testcontainers(MySQL コンテナ起動)
  • API 統合テスト:REST Assured
  • E2E テスト:Cypress(フロント)
  • パフォーマンス:Gatling で在庫検索 API を 500 同時ユーザーで負荷試験

デプロイ

# docker-compose.yml
version: "3.9"
services:
  app:
    build: .
    ports:
      - "8080:8080"
    depends_on:
      - db
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/usedcar?useSSL=false
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: usedcar
    volumes:
      - db-data:/var/lib/mysql
volumes:
  db-data:

本構成により、開発環境でも本番環境でも同一のコンテナイメージを使用し、CI/CD パイプラインへの組み込みが容易になる。

タグ: Spring Boot vue.js MySQL JWT websocket

6月27日 21:19 投稿