概要
紙ベースや 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 パイプラインへの組み込みが容易になる。