Spring Boot、Vue.js、およびUni-appを用いた設備部品管理・点検システムのアーキテクチャと実装

技術スタックの概要

バックエンド:Spring Boot

Spring Bootは、Tomcat、Jetty、Undertowなどのサーブレットコンテナを組み込んでいるため、WARファイルのデプロイを省略し、JARファイルとしてアプリケーションを直接実行できます。最大の特徴は「自動設定」機能であり、クラスパス上の依存関係に基づいてBeanの設定やライブラリのセットアップを自動的に行います。これにより、開発者は複雑なXML設定の手間を大幅に削減でき、ビジネスロジックの実装に集中できます。また、Spring Data、Spring Security、Spring Cloudといったエコシステムとの親和性も高く、マイクロサービスや拡張性の高いアプリケーションを迅速に構築するためのデファクトスタンダードとなっています。

フロントエンド:Vue.jsとUni-app

Vue.jsは、仮想DOM(Virtual DOM)を採用した軽量なJavaScriptフレームワークです。データバインディングがリアクティブに行われるため、データの変更が即座にUIに反映されます。これにより、DOM操作の手間から解放され、データ処理に専念できる開発体験を提供します。また、コンポーネント指向のアーキテクチャにより、UI部品の再利用性と保守性が向上します。本システムでは、Vue.jsの応用であるUni-appを使用することで、単一のコードベースからWebアプリケーションだけでなく、iOSやAndroidなどのモバイルアプリケーションも同時に展開可能なクロスプラットフォーム環境を構築しています。

データ永続化:MyBatis-Plus

MyBatis-Plusは、MyBatisを拡張したORMフレームワークであり、共通のCRUD操作をほとんどコードを書くことなく実装できます。MapperインターフェースにBaseMapperを継承させるだけで、挿入、更新、削除、選択などの基本機能が利用可能になります。さらに、条件構築にラムダ式を使用でき、タイプセーフなクエリ記述が可能です。ページング処理や楽観的ロック、SQLパフォーマンス分析などの実用的な機能も備えており、データアクセス層の開発効率を劇的に向上させます。

システムテスト

テストの目的

システムテストは、リリース前の品質を保証する最終段階です。本システムでは、ブラックボックステスト(黒箱テスト)手法を採用し、ユーザーの視点に立ってシステムの挙動を検証します。主な目的は、要件定義書で定義された機能が正しく動作するか、異常な入力やエッジケースに対してシステムが適切に振る舞うかを確認することです。テストを通じて潜在的なバグを特定し修正を行うことで、システムの信頼性とユーザビリティを向上させます。

機能テスト項目

1. ログイン機能検証

ユーザー認証プロセスの検証を行います。データベースに登録された情報との照合、エラーハンドリング、およびセッション管理の正当性を確認します。

テストシナリオ(入力データ) 期待される結果 実際の結果 判定
ユーザー名: admin
パスワード: 正しいパスワード
キャプチャ: 正解
システムメイン画面へ遷移 正常にログインし、ダッシュボードを表示 合格
ユーザー名: admin
パスワード: 誤ったパスワード
キャプチャ: 正解
認証エラーメッセージの表示 「パスワードが一致しません」というエラーを表示 合格
ユーザー名: admin
パスワード: 正しいパスワード
キャプチャ: 誤り
認証コードエラー 「画像認証コードが正しくありません」と表示 合格
ユーザー名: (空欄)
パスワード: 任意の文字列
キャプチャ: 正解
入力検証エラー 「ユーザー名を入力してください」と警告表示 合格

2. ユーザー管理機能検証

管理者によるユーザーアカウントの追加、更新、削除、および検索機能の整合性を確認します。データベース整合性制約とUIのフィードバックの一致に注目します。

操作内容 期待される結果 実際の結果 判定
新規ユーザー情報を入力して登録 リストに新規ユーザーが表示される データベースに保存され、一覧画面に即時反映 合格
既存ユーザーの情報を編集 変更内容が保存され表示される 更新後の情報で画面上のデータが書き換わる 合格
ユーザーを選択して削除実行 確認ダイアログの後、データが削除される 確認後に該当レコードが一覧から消滅 合格
必須項目(ユーザー名)を空にして登録 バリデーションエラー 「ユーザー名は必須です」というメッセージを表示 合格
重複するユーザー名で登録を試行 一意性制約エラー 「このユーザー名は既に使用されています」とエラー表示 合格

実装コード詳細

認証コントローラの実装

以下は、Spring Bootを使用した認証エンドポイントの実装例です。リファクタリングにより、セキュリティ強化と可読性の向上を図っています。ユーザー情報の検証からトークンの発行までのフローを示しています。

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

    @Autowired
    private AccountService accountService;

    @Autowired
    private JwtTokenService jwtTokenService;

    /**
     * ユーザーログイン処理
     */
    @PostMapping("/authenticate")
    public ApiResponse<LoginResult> authenticateUser(@RequestBody LoginDto loginDto, HttpServletRequest request) {
        // ユーザーの存在確認とパスワード検証
        SysUser user = accountService.findByUsername(loginDto.getUsername())
                .orElseThrow(() -> new BadCredentialsException("認証情報が無効です"));

        if (!PasswordEncoder.matches(loginDto.getPassword(), user.getHashedPassword())) {
            throw new BadCredentialsException("パスワードが一致しません");
        }

        // JWTトークンの生成(有効期限1時間)
        String accessToken = jwtTokenService.generateToken(user.getId(), user.getUsername(), user.getRole());

        // トークン情報のDB永続化
        jwtTokenService.saveOrUpdateToken(user, accessToken);

        return ApiResponse.success(new LoginResult(accessToken, user.getRole()));
    }
}

トークンサービスの実装

トークンの生成および管理ロジックです。`UUID`を使用してトークン文字列を生成し、有効期限を`LocalDateTime`で管理しています。既存のトークンがある場合は更新し、ない場合は新規作成する処理を実装しています。

@Service
public class JwtTokenService {

    @Autowired
    private AuthTokenRepository authTokenRepository;

    public String generateToken(Long userId, String username, String role) {
        // ランダムなトークン文字列を生成
        String tokenValue = UUID.randomUUID().toString().replace("-", "");
        
        // 有効期限を現在時刻から1時間後に設定
        LocalDateTime expiryDate = LocalDateTime.now().plusHours(1);

        // 既存トークンの確認と更新または新規登録
        AuthToken existingToken = authTokenRepository.findByUserIdAndRole(userId, role);
        
        if (existingToken != null) {
            existingToken.setTokenValue(tokenValue);
            existingToken.setExpiryDate(expiryDate);
            authTokenRepository.updateById(existingToken);
        } else {
            AuthToken newToken = new AuthToken();
            newToken.setUserId(userId);
            newToken.setUsername(username);
            newToken.setRole(role);
            newToken.setTokenValue(tokenValue);
            newToken.setExpiryDate(expiryDate);
            authTokenRepository.insert(newToken);
        }
        
        return tokenValue;
    }
}

認証インターセプターの実装

リクエストごとの権限チェックを行うインターセプターです。CORS(Cross-Origin Resource Sharing)のプリフライトリクエストへの対応や、ヘッダーからのトークン抽出、セッション属性への設定を行っています。`@PermitAll`アノテーションが付与されたメソッドは認証をスキップします。

@Component
public class SecurityInterceptor implements HandlerInterceptor {

    public static final String AUTH_HEADER = "X-Auth-Token";

    @Autowired
    private JwtTokenService jwtTokenService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // CORS設定
        setCorsHeaders(response);

        // プリフライトリクエスト(OPTIONS)は許可
        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            response.setStatus(HttpServletResponse.SC_OK);
            return false;
        }

        // アノテーションによる認証スキップチェック
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            if (handlerMethod.getMethodAnnotation(PermitAll.class) != null) {
                return true;
            }
        }

        // トークンの取得と検証
        String token = request.getHeader(AUTH_HEADER);
        if (StringUtils.isEmpty(token)) {
            sendErrorResponse(response, 401, "認証トークンが見つかりません");
            return false;
        }

        AuthToken authToken = jwtTokenService.validateToken(token);
        if (authToken == null || authToken.getExpiryDate().isBefore(LocalDateTime.now())) {
            sendErrorResponse(response, 401, "トークンの有効期限が切れているか、無効です");
            return false;
        }

        // セッション情報の設定
        request.getSession().setAttribute("currentUserId", authToken.getUserId());
        request.getSession().setAttribute("currentUserRole", authToken.getRole());
        request.getSession().setAttribute("currentUsername", authToken.getUsername());

        return true;
    }

    private void setCorsHeaders(HttpServletResponse response) {
        response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
        response.setHeader("Access-Control-Max-Age", "3600");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Auth-Token, Authorization");
        response.setHeader("Access-Control-Allow-Origin", "*");
    }

    private void sendErrorResponse(HttpServletResponse response, int status, String message) throws IOException {
        response.setStatus(status);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"code\":" + status + ",\"message\":\"" + message + "\"}");
    }
}

データベース設計

認証トークンテーブル定義

セキュリティ管理の中核となるトークン情報を格納するデータベーススキーマです。ユーザーID、ロール、トークン文字列、および有効期限を管理します。

-- ----------------------------
-- Table structure for auth_tokens
-- ----------------------------
DROP TABLE IF EXISTS `auth_tokens`;
CREATE TABLE `auth_tokens` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主キーID',
  `user_id` bigint(20) NOT NULL COMMENT 'ユーザーID',
  `username` varchar(100) NOT NULL COMMENT 'アカウント名',
  `role` varchar(50) NOT NULL COMMENT 'ロール名',
  `token_value` varchar(255) NOT NULL COMMENT 'アクセストークン',
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '作成日時',
  `expiry_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '有効期限',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_token` (`token_value`),
  KEY `idx_user_role` (`user_id`, `role`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='認証トークン管理テーブル';

-- ----------------------------
-- Initial Data (Sample)
-- ----------------------------
INSERT INTO `auth_tokens` (`user_id`, `username`, `role`, `token_value`, `expiry_date`) VALUES
(1, 'admin', 'ADMIN', 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6', DATE_ADD(NOW(), INTERVAL 1 HOUR)),
(23, 'operator01', 'ENGINEER', 'z9y8x7w6v5u4t3s2r1q0p9o8n7m6l5k4', DATE_ADD(NOW(), INTERVAL 1 HOUR)),
(45, 'user_suzuki', 'USER', 'm1n2b3v4c5x6z7l8k9j0h1g2f3d4s5a6', DATE_ADD(NOW(), INTERVAL 1 HOUR));

8月8日 19:18 投稿