システム概要
本記事では、Spring Bootをバックエンドに、Vue.jsを管理画面に、uni-appをモバイルアプリケーションに採用した、緊急物資管理システムの技術的実装について解説します。災害発生時における物資の在庫管理、配布調整、需要予測を統合的に行うことが可能なアーキテクチャを構築しています。
技術スタックの選定理由
バックエンド:Spring Boot 3.x
Servletコンテナの組み込み機能により、Tomcatの別途設定不要で独立した実行ファイルとしてデプロイ可能です。自動設定機構により、依存関係に基づいたスマートなコンポーネント設定が行われ、従来のXMLベースの煩雑な設定を排除できます。また、Spring Security、Spring Data JPAとの統合がスムーズで、認証・認可機能の実装が効率化されます。
フロントエンド:Vue 3 + TypeScript
Composition APIとリアクティビティシステムを活用したコンポーネント設計により、複雑な状態管理を直感的に記述できます。Viteによるビルドツールチェインの採用で、開発時のホットリロード速度を大幅に向上させています。管理画面ではElement PlusをUIフレームワークとして利用し、一貫性のある操作性を実現しています。
モバイル:uni-app
単一のコードベースでiOS、Android、各種ミニプログラムに対応可能なクロスプラットフォームフレームワークです。Vue.jsライクな構文で開発でき、ネイティブパフォーマンスに近い体験を提供します。緊急時の現地対応におけるオフライン機能も、ローカルストレージ連携で実装可能です。
データアクセス:MyBatis-Plus
MyBatisの機能を拡張し、CRUD操作をゼロコーディングで実現する強力なツールです。Lambda式を用いたタイプセーフなクエリ構築や、ページネーション、論理削除、楽観的ロックなどの機能をアノテーション一つで有効化できます。コードジェネレーターによるEntity、Mapper、Service層の自動生成も可能で、開発生産性の向上に貢献します。
コアアーキテクチャ
認証・認可機構
ステートレスなトークンベース認証を採用しています。ログイン成功時にサーバーはUUIDベースのトークンを生成し、有効期限と共にRedisにキャッシュします。クライアントは以降のリクエストでHTTPヘッダーにX-Auth-Tokenとしてトークンを付与します。サーバー側ではSpring MVCのHandlerInterceptorでトークンの検証とリフレッシュを自動処理します。
実装コード例
1. 認証エンドポイント
@RestController
@RequestMapping("/api/auth")
public class AuthenticationController {
@Autowired
private AccountService accountService;
@Autowired
private SessionManager sessionManager;
@PostMapping("/authenticate")
public ResponseEntity<AuthResult> authenticate(
@RequestBody @Valid LoginCredentials credentials) {
UserAccount user = accountService.verifyCredentials(
credentials.getLoginId(),
credentials.getSecretKey()
);
if (user == null) {
throw new UnauthorizedException("認証情報が無効です");
}
String accessToken = sessionManager.createSession(
user.getUserId(),
user.getLoginName(),
user.getAccountType()
);
return ResponseEntity.ok(new AuthResult(accessToken, user));
}
}
2. トークン管理サービス
@Service
public class SessionManager {
@Autowired
private TokenRepository tokenRepository;
private static final Duration TOKEN_TTL = Duration.ofHours(2);
public String createSession(Long userId, String loginName, UserRole role) {
String tokenValue = UUID.randomUUID().toString().replace("-", "");
Instant expireAt = Instant.now().plus(TOKEN_TTL);
AccessToken token = new AccessToken();
token.setTokenValue(tokenValue);
token.setUserId(userId);
token.setLoginName(loginName);
token.setUserRole(role);
token.setExpireTime(expireAt);
tokenRepository.insert(token);
return tokenValue;
}
public AccessToken validateToken(String tokenValue) {
AccessToken token = tokenRepository.selectByToken(tokenValue);
if (token == null || token.getExpireTime().isBefore(Instant.now())) {
return null;
}
// 有効期限の更新
token.setExpireTime(Instant.now().plus(TOKEN_TTL));
tokenRepository.updateExpireTime(token);
return token;
}
}
3. 認証インターセプター
@Component
public class TokenValidationInterceptor implements HandlerInterceptor {
private static final String AUTH_HEADER = "X-Auth-Token";
@Autowired
private SessionManager sessionManager;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// CORSプリフライトリクエストの処理
if (HttpMethod.OPTIONS.matches(request.getMethod())) {
response.setStatus(HttpStatus.OK.value());
return false;
}
// パブリックAPIの判定
if (handler instanceof HandlerMethod) {
HandlerMethod method = (HandlerMethod) handler;
if (method.hasMethodAnnotation(PublicApi.class)) {
return true;
}
}
String tokenValue = request.getHeader(AUTH_HEADER);
if (StringUtils.isBlank(tokenValue)) {
writeErrorResponse(response, HttpStatus.UNAUTHORIZED, "トークンが不足しています");
return false;
}
AccessToken token = sessionManager.validateToken(tokenValue);
if (token == null) {
writeErrorResponse(response, HttpStatus.FORBIDDEN, "無効なトークンです");
return false;
}
// リクエスト属性にユーザー情報を設定
request.setAttribute("currentUserId", token.getUserId());
request.setAttribute("currentRole", token.getUserRole());
return true;
}
private void writeErrorResponse(HttpServletResponse response,
HttpStatus status,
String message) throws IOException {
response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
Map<String, Object> error = new HashMap<>();
error.put("code", status.value());
error.put("message", message);
response.getWriter().write(new ObjectMapper().writeValueAsString(error));
}
}
データベース設計
トークン管理テーブル
CREATE TABLE access_token (
token_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'トークン識別子',
user_id BIGINT UNSIGNED NOT NULL COMMENT 'ユーザー識別子',
login_name VARCHAR(50) NOT NULL COMMENT 'ログイン名',
user_role VARCHAR(20) NOT NULL COMMENT '役割',
token_value VARCHAR(64) NOT NULL UNIQUE COMMENT 'トークン文字列',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '発行日時',
expire_time TIMESTAMP NOT NULL COMMENT '有効期限',
PRIMARY KEY (token_id),
INDEX idx_token_value (token_value),
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='アクセストークン管理テーブル';
品質保証アプローチ
テスト戦略
機能テスト、統合テスト、E2Eテストの3層で品質を担保します。JUnit 5とMockitoを用いた単体テストでは、ビジネスロジックの網羅率を85%以上を目標としています。Spring Testによる統合テストでは、実データベースを使用したトランザクション動作を検証します。
主要テストシナリオ
| テスト項目 | 入力条件 | 期待結果 | 実際の結果 |
|---|---|---|---|
| 正常ログイン | loginId: admin, secretKey: correctPassword | 200 OK, トークン発行 | トークン付与成功 |
| パスワード誤り | loginId: admin, secretKey: wrongPassword | 401 Unauthorized | 認証エラー表示 |
| トークン未指定 | Authorizationヘッダーなし | 403 Forbidden | アクセス拒否 |
| トークン期限切れ | 2時間経過後のトークン | 403 Forbidden | セッションタイムアウト |
| ユーザー登録(バリデーション) | loginName: null | 400 Bad Request | 入力チェックエラー |
テスト実施により、全ての重要機能において仕様通りの動作を確認しました。特に認証フローにおいては、並行アクセス時のトークン競合を防ぐための排他制御も検証済みです。