Spring BootとVue・uni-appを活用した映画館予約・座席選択システムのアーキテクチャと実装

システムアーキテクチャと技術スタック

本システムは、Spring BootによるRESTful APIバックエンドと、Vue.jsおよびuni-appで構築されたクライアント側を組み合わせ、映画館のチケット予約および座席指定機能を実現する。データアクセス層にはMyBatis-Plusを採用し、クエリ構築の効率化とメンテナンス性の向上を図っている。

バックエンド:Spring Boot

TomcatやUndertowなどの組込みWebサーバーを標準搭載し、外部アプリケーションサーバーの依存を排除する。依存関係の自動検出と設定(Auto-configuration)により、初期構成コードを最小化し、Spring SecurityやSpring Dataとの連携によるセキュアなエンドポイント設計を支援する。プラグインエコシステムを活用することで、スケーラビリティと拡張性を確保している。

フロントエンド:Vue.js & uni-app

仮想DOMによる差分レンダリングと、リアクティブなデータバインディング機構を採用。状態変更が自動的にUIに反映されるため、座席選択グリッドの描画パフォーマンスや予約フォームの状態管理が最適化される。コンポーネントベースの設計により、コードの再利用性と保守性を確保し、マルチプラットフォーム(WeChat Mini Programなど)へのスムーズな移植を可能にする。

データアクセス層:MyBatis-Plus

MyBatisの拡張として提供される高生産性ORMツール。基本的なCRUD操作の自動生成、動的SQL構築、ページネーションサポート、および楽観的ロック機能を内蔵。コードジェネレーターを活用することで、エンティティクラスとマッパーインタフェースの定義作業を自動化し、開発サイクルを短縮する。

認証機構とセッション管理の実装

システムへのアクセス制御は、独自トークンベースのセッション検証により実現される。ログインエンドポイントは入力資格情報を検証し、有効期限付きのアクセスキーを生成して返却する。各リクエストではインターセプタがヘッダー情報を検査し、認可が確認された場合のみリクエストコンテキストにユーザー情報をバインドする。


@RestController
@RequestMapping("/v1/auth")
@RequiredArgsConstructor
public class AuthenticationEndpoint {
    private final UserRepository userRepo;
    private final TokenService tokenSvc;

    @PostMapping("/access")
    public ResponseEntity<ApiResponse> processLogin(@Valid @RequestBody LoginPayload payload) {
        UserRecord user = userRepo.fetchByIdentifier(payload.getAccountName())
            .orElseThrow(() -> new UnauthorizedException("認証情報が登録されていません"));

        if (!SecurityUtil.verify(payload.getCredential(), user.getEncryptedSecret())) {
            return ResponseEntity.status(400).body(ApiResponse.reject("認証に失敗しました"));
        }

        String issuedKey = tokenSvc.createAccessKey(user.getId(), user.getPrivilegeLevel());
        return ResponseEntity.ok(ApiResponse.success(Map.of("session_id", issuedKey)));
    }
}

@Service
@RequiredArgsConstructor
public class StandardTokenService implements TokenService {
    private final SessionMapper sessionMapper;

    @Override
    public String createAccessKey(Long uid, String level) {
        String newKey = CryptoUtil.randomAlphaNumeric(36);
        LocalDateTime expiry = LocalDateTime.now().plusMinutes(120);
        SessionRecord current = sessionMapper.lookupActiveKey(uid, level);

        if (current != null) {
            sessionMapper.renewSession(current.getId(), newKey, expiry);
        } else {
            sessionMapper.save(new SessionRecord(uid, level, newKey, expiry));
        }
        return newKey;
    }
}

@Component
@RequiredArgsConstructor
public class RequestGuard implements HandlerInterceptor {
    private static final String AUTH_HEADER = "X-Cinema-Token";
    private final TokenService tokenSvc;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            response.setStatus(HttpStatus.NO_CONTENT.value());
            return false;
        }

        response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Max-Age", "3600");

        String token = request.getHeader(AUTH_HEADER);
        if (!StringUtils.hasText(token)) {
            return rejectRequest(response, 401, "認可ヘッダーが欠落しています");
        }

        SessionRecord session = tokenSvc.validateToken(token);
        if (session == null) {
            return rejectRequest(response, 401, "セッションが期限切れまたは無効です");
        }

        request.setAttribute("CONTEXT_USER_ID", session.getUserId());
        request.setAttribute("CONTEXT_PRIVILEGE", session.getRole());
        return true;
    }

    private boolean rejectRequest(HttpServletResponse res, int code, String message) throws IOException {
        res.setStatus(code);
        res.setContentType("application/json;charset=UTF-8");
        res.getWriter().write("{\"status\":" + code + ",\"msg\":\"" + message + "\"}");
        return false;
    }
}

データベース設計(セッション管理表)

発効されたトークン情報は、MySQLのInnoDBエンジンを使用して永続化される。セッションキーはユニーク制約が適用され、有効期限管理のためにインデックスが設定されている。


CREATE TABLE `system_sessions` (
  `record_id` BIGINT(20) NOT NULL AUTO_INCREMENT,
  `user_ref` BIGINT(20) NOT NULL COMMENT 'ユーザー識別子',
  `account_name` VARCHAR(64) NOT NULL,
  `privilege_role` VARCHAR(32) DEFAULT NULL COMMENT '権限レベル',
  `access_key` VARCHAR(64) NOT NULL UNIQUE COMMENT '認証トークン',
  `issued_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  `valid_until` TIMESTAMP NOT NULL DEFAULT '2030-01-01 00:00:00',
  PRIMARY KEY (`record_id`),
  INDEX `idx_valid_until` (`valid_until`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='認証セッション格納表';

INSERT INTO `system_sessions` VALUES (1, 101, 'staff_01', 'employee', 'k8d9f2m1n4p7q3s6v9x2z5b8c1', '2024-05-10 14:20:00', '2024-05-10 16:20:00');
INSERT INTO `system_sessions` VALUES (2, 102, 'admin_root', 'superuser', 'm2p5s8v1y4b7e0h3k6n9q2t5w8', '2024-05-10 15:05:30', '2024-05-10 17:05:30');
INSERT INTO `system_sessions` VALUES (3, 103, 'manager_a', 'admin', 'x7y0z3c6f9j2m5p8r1u4w7a0', '2024-05-11 09:12:45', '2024-05-11 11:12:45');

機能検証とテスト実施

システムの安定性を確保するため、ブラックボックステスト手法に基づき、入力境界値・必須項目検証・権限チェックを中心に検証を行う。以下のケースは認証フローとユーザー管理モジュールの検証結果を示している。

ログイン認証の検証シナリオ

入力パラメータ期待動作実測結果検証判定
ID: admin_01 / PW: ****** / Key: 正常認証成功・トークン発行セッションキー返却合格
ID: admin_01 / PW: 誤り / Key: 正常認証拒否・エラー応答400ステータス・認証失敗メッセージ合格
ID: admin_01 / PW: ****** / Key: 誤り検証不一致エラー400ステータス・検証不一致警告合格
ID: 空欄 / PW: ****** / Key: 正常必須項目欠落警告400ステータス・ID未入力提示合格
ID: admin_01 / PW: 空欄 / Key: 正常パスワード欠落警告400ステータス・PW未入力提示合格

ユーザー情報管理の検証シナリオ

操作パターン期待動作実測結果検証判定
新規登録(全項目正常)データ永続化・一覧反映データベース保存確認・UIリスト更新合格
既存データ編集更新内容の反映変更値が正常に適用され画面表示合格
レコード削除削除確認後のデータ消去削除確認ダイアログ経由で対象消去合格
ID未入力登録バリデーション阻害「識別子は必須です」警告表示合格
重複ID登録一意性制約違反検知「既に使用されています」エラー応答合格

タグ: spring-boot vue.js MyBatis-Plus UniApp rest-api

8月5日 11:52 投稿