Spring BootとVue.js、uni-appを活用した緊急物資管理システムの開発と実装

システム概要

本記事では、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 入力チェックエラー

テスト実施により、全ての重要機能において仕様通りの動作を確認しました。特に認証フローにおいては、並行アクセス時のトークン競合を防ぐための排他制御も検証済みです。

タグ: Spring Boot vue.js uni-app MyBatis-Plus トークン認証

5月19日 09:56 投稿