Files
em-spring/研修生用実装手順書.md
m-mun 9a5b0e1adc pre
2026-10-14 08:31:13 +09:00

72 KiB
Raw Permalink Blame History

部署管理機能 実装手順書(研修生用)

この手順書は、README.md の課題「部署テーブルの CRUD 機能追加」を、一歩ずつ進められるようにした道しるべです。

  • コードは 「写して終わり」にならないよう、考えてほしい部分を /* ★考える */ で空けてあります。空欄は、既存の社員機能(お手本)を読めば埋められます。
  • 各ステップの最後に 「できたかの確認」 があります。確認できるまで次へ進まないでください。
  • 詰まったら、先に「詰まったとき」(第10章) を読み、それでも解決しない場合は 30分 悩んだ時点で相談してください。相談するときは「やりたいこと/期待した結果/実際の結果(エラー全文)/試したこと/自分の考え」をそろえてください。

目次

  1. 全体像(何を作るのか・進め方)
  2. 準備(環境・DB・起動確認)
  3. 実装前に読むもの(読解ワーク)
  4. 作業の基本ルール(ファイルの置き場所・名前)
  5. 第1段階:検索
  6. 第2段階:削除
  7. 第3段階:更新(ここまでが提出可能ライン)
  8. 第4段階:新規登録(チャレンジ)
  9. 第5段階:アクセス制御アノテーション(チャレンジその2)
  10. テストの進め方(テスト仕様書の使い方)
  11. 詰まったとき(エラー別の調べ方)
  12. 提出前チェックリスト

0. 全体像

0.1 作るもの

管理者が「部署」を 検索・削除・更新・新規登録 できる画面です。すでにある「社員」の機能と同じ作りで作ります。

段階 内容 到達目標
1 検索 メニューから部署検索画面を開き、部署ID(完全一致)・部署名(部分一致)で検索できる
2 削除 検索結果の各行から削除できる
3 更新 検索結果の各行から部署名を更新できる(提出可能ライン)
4 新規登録 入力 → 確認 → 登録 → 完了 の3画面で登録できる
5 アクセス制御 未ログイン・一般ユーザーは部署画面に入れない

0.2 プログラムの構造(これだけは覚える)

画面でボタンを押してから、DBに届くまでの流れです。

[ブラウザ]  ボタン押下
   │ ① POST で送信(例:/selectDepartment)
   ▼
[Controller]  presentation/controller/…Controller.java
   │  受け取る・どの画面に行くか決める・メッセージを用意する
   │ ② Form ⇄ Model に詰め替え
   ▼
[Service]     domain/service/…(インタフェース) と impl/…Impl.java(実装)
   │  業務の処理(検索する・削除する、など)
   ▼
[Repository]  infra/repository/…Repository.java   ←すでに用意済み
   ▼
[MySQL]       department テーブル
   ▲
   └ 結果は逆向きに戻り、Controller が [HTML(Thymeleaf)] に渡して画面になる
部品 役割 部署で使うもの
Form 画面とControllerの間で値を運ぶ箱 DepartmentForm(既存)、他は自作
Model 業務で使うデータ Department、SearchConditionDepartment(既存)
Entity DBの1行(infra層専用) DepartmentEntity(既存)

0.3 すでにあるもの・作るもの

すでにある(触らない) 自分で作る
DepartmentEntity、DepartmentRepository Form 3つ
Department、SearchConditionDepartment Service:削除・更新(IF+実装)、登録(実装のみ)
DepartmentForm Controller 7つ
SearchDepartment(検索サービス) HTML 5つ
RegisterDepartment(インタフェースだけ) menu.html にリンク、定数の追記
アノテーション @SessionCheck @PermissionCheck

0.4 進め方のコツ

  1. お手本(社員機能)を必ず動かして、コードを読んでから書く。
  2. 一度に全部書かない。1ファイルできたらビルド・起動して確認する。
  3. エラーは一番下の Caused by から読む(第10章)。
  4. 変更は1つずつ。直らなかったら元に戻して別の仮説を試す。
  5. 第1〜4段階では @PermissionCheck / @SessionCheck は付けない(第5段階で付けます)。

1. 準備

1.1 必要なもの

項目 内容 確認方法
JDK 17 17以外は不可(JDK 25 では Lombok が動かずビルドエラーになります) コマンドで java -version
MySQL 8 起動していること 接続できる
Eclipse README の手順でプロジェクトをインポート済み 赤い×が出ていない

1.2 DBを初期状態にする(README「課題に取りかかる前に」)

  1. 未作成なら MySQL で DB とユーザーを作る。設計書/start-db-ddl-dml.sql の先頭に コメントアウトされた 次の3行があります。必要ならコメントを外して実行します。
    CREATE DATABASE rezodb;
    CREATE USER 'rezouser'@'localhost' IDENTIFIED BY 'Rezo_0000';
    GRANT ALL ON rezodb.* TO 'rezouser'@'localhost';
    
  2. A5:SQL Mk-2 等で 設計書/start-db-ddl-dml.sql を全部実行する。
    • このファイルの文字コードは Shift-JIS です。UTF-8のエディタで開くと文字化けします(A5M2 から実行してください)。
  3. 確認:department に6件、employee に10件。
id_department nm_department
10 役員
11 人事・総務部
12 経理部
13 システム開発事業部
14 教育事業部
15 営業企画部

テーブルが無いまま起動すると、ログインの時点で Table 'rezodb.employee' doesn't exist のエラーになります。

1.3 ビルドと起動

README のとおり、Eclipse で プロジェクトを右クリック → 実行 → maven clean → maven install。またはプロジェクト直下の build.cmd。起動は run.cmd(または Eclipse からアプリを実行)。

ブラウザで http://localhost:8080/ を開き、ログインできることを確認します。

種類 メールアドレス パスワード ログイン後
管理者 yamada@hoge.jp aaa メニュー画面
一般 jtaka@hoge.jp bbb 個人情報更新画面

1.4 できたかの確認

  • 管理者でログインし、メニューが表示された
  • 「社員検索」「社員登録」を一通り操作できた(お手本の動きを体験する)

2. 実装前に読むもの(読解ワーク)

書き始める前に必ず次を読み、下の「課題理解シート」を埋めてください(README にも「何を作るか・どこまで作るか理解してから作業を始める」とあります)。

  1. README.md、ヒント.md
  2. 設計書/【管理者】部署情報検索更新削除機能-詳細設計書.xlsx
  3. 設計書/【管理者】部署情報登録機能-詳細設計書.xlsx
  4. 設計書/【管理者】メニュー機能-詳細設計書.xlsx
  5. テスト仕様書/単体テスト仕様書.xlsx の「部署管理テスト仕様書」シート
  6. 既存の社員機能のコード(第3章の表の「お手本」)

課題理解シート(ノート等に書く)

1. 作るもの(1行):
2. 画面の一覧(画面名/何をする画面か):
3. 画面ごとのボタンと、押したときの動き:
4. 画面に出すメッセージ(文言/どんなとき/どう出す):
5. 使うテーブルと項目(型・桁数):
6. 作るファイルの一覧と、それぞれのお手本にする既存ファイル:
7. 分からないこと:

設計書を読むときのポイント

  • 「画面項目定義」=画面にある部品の一覧。「イベント処理」=ボタンを押したときの処理手順。
  • メッセージの文言は一字一句そのまま使う(テスト仕様書で確認されます)。
  • 設計書には、社員機能からのコピー由来と思われる誤記がいくつかあります(例:部署の設計書に「社員登録画面」「社員マスタに登録」など)。おかしいと思った所はメモして質問してください。
  • テーブル定義書と実際のDDLで、部署名の桁数が食い違っています(定義書は50、DDLは VARCHAR(10))。第7章で実際に確認します。気づいたらメモしておいてください。

メッセージ一覧(設計書・テスト仕様書より)

場面 文言 出し方
検索結果0件 検索結果はありません。 画面内の文字
削除ボタン 本当に削除しますか? 確認ダイアログ
削除:対象IDが不明 削除対象のIDが不明です。 エラー表示
削除:社員が所属している 1名以上が所属している部署は削除出来ません。 エラー表示
削除成功 削除しました。 アラート
更新ボタン 本当によろしいですか? 確認ダイアログ
更新:対象がない 更新対象のデータが見つかりません。 エラー表示
更新:同名あり/変更なし 既に同名の部署が存在するか、値が変更されていません。 エラー表示
更新:空欄 項目が空欄です。 エラー表示
更新成功 更新が完了しました。 アラート
登録:部署名が不正 部署名が不正です。 エラー表示
登録:同名あり 既に同名の部署が存在します。 エラー表示
登録成功 部署登録が完了しました 完了画面の見出し

3. 作業の基本ルール

3.1 ファイルの置き場所

ベースは src/main/java/com/example/rhizome/ です(以下 …/ と略します)。

種類 置き場所 作るファイル
Form …/app/presentation/form/ SearchConditionDepartmentForm、UpdateDepartmentForm、InsertDepartmentForm
Controller …/app/presentation/controller/ SelectDepartmentController、DeleteDepartmentController、UpdateDepartmentController、UpdateDepartmentResultController、InsertDepartmentInputController、InsertDepartmentConfirmController、InsertDepartmentController
Service(IF) …/app/domain/service/ DeleteDepartment、UpdateDepartment
Service(実装) …/app/domain/service/impl/ DeleteDepartmentImpl、UpdateDepartmentImpl、RegisterDepartmentImpl
定数 …/app/presentation/controller/pageproperty/ 既存の TransitionTargetPageNameKeyword、PageReturnAttributeKeyword に追記
HTML src/main/resources/templates/ selectDepartment、updateDepartmentInput、insertDepartmentInput、insertDepartmentConfirm、insertDepartmentResult(拡張子 .html)、既存 menu.html に追記

パッケージの注意:すべて com.example.rhizome の配下に置きます。外に置くと Spring が見つけられず、起動時に NoSuchBeanDefinitionException になります。

3.2 お手本(社員機能)との対応

作るもの お手本
SelectDepartmentController SelectEmployeeController
SearchConditionDepartmentForm SearchConditionEmployeeForm
selectDepartment.html selectEmployee.html
DeleteDepartmentController、DeleteDepartment(Impl) DeleteEmployeeController、DeleteEmployee(Impl)
UpdateDepartmentController / …ResultController UpdateEmployeeController / UpdateEmployeeResultController
UpdateDepartmentForm、updateDepartmentInput.html UpdateEmployeeInfoForm、updateEmployeeInput.html
UpdateDepartment(Impl) UpdateEmployee(Impl)
InsertDepartment…Controller、InsertDepartmentForm、insertDepartment…html InsertEmployee…Controller、InsertEmployeeForm、insertEmployee…html
RegisterDepartmentImpl RegisterEmployeeImpl

お手本は「構造を学ぶ」ために使います。 名前を置換してコピーするだけでは、部署に不要な処理(部署のプルダウン、パスワード等)が残って動かなかったり、意味が分からないままになります。各行が何をしているか説明できる状態で写してください。

3.3 コードの書き方の約束(既存に合わせる)

  • 変数・フィールド名:部署ID=idDepartment、部署名=nmDepartment
  • クラスは @Controller / @Service / Lombok の @Data 等を、お手本と同じように使う
  • 画面名・URL・画面に渡すキーは文字列を直書きせず定数にする(TransitionTargetPageNameKeyword / PageReturnAttributeKeyword)
  • Controller の処理は、お手本と同様に try { … } catch (Exception e) { … } で囲む

4. 第1段階:検索

4.0 ゴールと完成の定義

メニューの「部署検索」から部署検索画面に行き、部署ID=完全一致/部署名=部分一致 で検索できる。検索後も入力した条件が画面に残る。0件なら 検索結果はありません。。 → 単体テスト #1〜#8 が期待どおりになれば完成。

完成後の流れ:

menu.html「部署検索」 ──POST /selectDepartment──▶ SelectDepartmentController
                                                       └──▶ selectDepartment.html(検索条件+結果)

ステップ 1-1:お手本を動かして、コードで追う

  1. 社員検索を実行する。
  2. SelectEmployeeController の select メソッドの最初の行にブレークポイントを置いて、デバッグ実行し、ステップ実行で追う。
  3. 次の質問に自分の言葉で答える(答えられなければコードを読み直す)。
    • Form に値が入るのは、どの時点・誰の仕事?
    • select メソッドが2つあるのはなぜ?(片方は引数に検索条件の Form を持つ、片方は持たない。この違いは第2段階で効いてきます)
    • 検索サービスは最終的にどこでSQLを作っている?(EmployeeInfoRepository の Specification)

できたかの確認:ホワイトボードやノートに、§0.2 のような流れ図を自分で描ける。

ステップ 1-2:定数を追加する

TransitionTargetPageNameKeyword.java には、すでに // Department と書かれた空の枠があります。ここに追記します。

  • HTML名:templates のファイル名(拡張子なし)。先頭に / は付けない。
  • URL:Controller のURL。先頭に / を付ける。
// Department
// HTML
public static final String SELECT_DEPARTMENT_HTML = "selectDepartment";

// Controller
public static final String SELECT_DEPARTMENT = "/selectDepartment";

menu.html にある既存のJavaScript(selectDepartmentSubmit())は、selectDepartment という名前に送信します。上の定数はそれに合わせています。

できたかの確認:ビルドが通る(まだ画面は動かなくてよい)。

ステップ 1-3:検索条件の Form を作る

…/presentation/form/SearchConditionDepartmentForm.java

package com.example.rhizome.app.presentation.form;

import com.example.rhizome.app.domain.model.SearchConditionDepartment;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 画面から渡ってきた部署の検索条件をまとめ、再度画面に表示する為のクラス
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SearchConditionDepartmentForm {

    /** 部署ID */
    private Integer idDepartment;
    /** 部署名 */
    private String nmDepartment;

    /**
     * Form から business 層の SearchConditionDepartment に変換する
     */
    public static SearchConditionDepartment convertTo(SearchConditionDepartmentForm form) {
        return new SearchConditionDepartment(/* ★考える:Form の2つの値を渡す */);
    }
}

考えるポイント:

  • 部署IDが int ではなく Integer なのはなぜ?(未入力=null を表したい)
  • @NoArgsConstructor が無いと何が困る?(画面の値を自動で詰める時に、空のインスタンスが必要)

できたかの確認:ビルドが通る。「Form/Model/Entity の違い」を3行で説明できる。

ステップ 1-4:検索サービスの使い方を調べる(コードは書かない)

SearchDepartment(インタフェース)には3つのメソッドがあります。

メソッド 動き
selectAll() 全件取得
searchBy(条件) 部署ID=完全一致、部署名=実質完全一致
searchByWithLike(条件) 部署ID=完全一致、部署名=部分一致

searchBy は内部で like を使っていますが、% を付けないので完全一致と同じです。メソッド名に惑わされず、SearchDepartmentImpl と DepartmentRepository を読んで確認してください。

課題:今回の仕様「部署名は部分一致」を満たすのはどちらか。実際に両方試して(部署名に 営業 と入れて)確認し、理由をノートに書く。

ステップ 1-5:検索 Controller を作る

…/presentation/controller/SelectDepartmentController.java(お手本:SelectEmployeeController)

package com.example.rhizome.app.presentation.controller;

import com.example.rhizome.app.domain.service.SearchDepartment;
import com.example.rhizome.app.presentation.controller.pageproperty.PageReturnAttributeKeyword;
import com.example.rhizome.app.presentation.controller.pageproperty.TransitionTargetPageNameKeyword;
import com.example.rhizome.app.presentation.form.DepartmentForm;
import com.example.rhizome.app.presentation.form.SearchConditionDepartmentForm;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

import javax.servlet.http.HttpSession;
import java.util.logging.Level;
import java.util.logging.Logger;

@Controller
public class SelectDepartmentController {

    private final static Logger LOGGER = Logger.getLogger(SelectDepartmentController.class.getName());
    private final static String SEVERE_ERROR_MESSAGE = "エラーが発生しました!";

    HttpSession httpSession;
    SearchDepartment searchDepartment;

    @Autowired
    public SelectDepartmentController(HttpSession httpSession, SearchDepartment searchDepartment) {
        this.httpSession = httpSession;
        this.searchDepartment = searchDepartment;
    }

    /** 空の検索条件Formを、この Controller の画面用に用意する */
    @ModelAttribute
    public SearchConditionDepartmentForm searchConditionDepartmentForm() {
        return new SearchConditionDepartmentForm();
    }

    @RequestMapping(value = TransitionTargetPageNameKeyword.SELECT_DEPARTMENT, method = RequestMethod.POST)
    public String select(SearchConditionDepartmentForm searchConditionDepartmentForm, Model model) {
        try {
            // ★考える:①検索サービスを呼ぶ(Form→Model変換 → 部分一致のメソッド)
            //          ②結果(List<Department>)を DepartmentForm.convertFrom(...) で画面用に変換
            //          ③ model に PageReturnAttributeKeyword.DEPARTMENT_FORM_LIST という名前で載せる

            return TransitionTargetPageNameKeyword.SELECT_DEPARTMENT_HTML;

        } catch (Exception e) {
            httpSession.invalidate();
            e.printStackTrace();
            LOGGER.log(Level.SEVERE, SEVERE_ERROR_MESSAGE, e);
            return TransitionTargetPageNameKeyword.ERROR_HTML;
        }
    }
}

知っておくこと:

  • @ModelAttribute のメソッドが返した Form は、searchConditionDepartmentForm(クラス名の先頭小文字)という名前で画面に渡ります。これは この Controller クラスの中だけで有効です。
  • select(SearchConditionDepartmentForm form, …) の引数 Form は、自動的に同じ名前で画面に渡されます。だから検索後も入力値が画面に残ります。
  • メニューからの遷移はパラメータ無しのPOSTです。条件が null や空だと全件が返ります(Specification で条件が外れる)。

できたかの確認:ビルドが通る。

ステップ 1-6:検索画面 HTML を作る

src/main/resources/templates/selectDepartment.html(お手本:selectEmployee.html)

お手本の次の部分はそのまま使えます:<head>(CSS・JS読み込み)、見出しとログイン者名・ログアウトボタン、メッセージ表示、「メニューに戻る」リンク。

骨格(★ の所を、お手本を見て完成させます):

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
    <script th:src="@{/js/common.js}"></script>
    <link rel="stylesheet" th:href="@{/css/all.css}">
    <link rel=stylesheet th:href="@{/css/style.css}" type="text/css">
    <title>部署検索</title>
</head>
<body>
<h1>
    部署検索条件入力   
    <div class="rightSection">
        <span class="loginInfo">ログイン者名:<span th:text="${session.loginUser.nmEmployee}"></span></span>
        <button class="logoutButton" onclick="logoutSubmit()">ログアウト</button>
    </div>
</h1>

<form action="selectDepartment" method="post" th:object="${searchConditionDepartmentForm}">
    <div class="msgBox error" th:if="${messageError} != null">
        <span class="error" th:text="${messageError.message}"></span>
    </div>
    <table>
        <tr>
            <th class="header">部署ID</th>
            <td><input placeholder="部署ID" th:field="*{idDepartment}" type="number"/></td>
        </tr>
        <tr>
            <th class="header">部署名</th>
            <td><!-- ★部署名の入力欄(type="text")を作る --></td>
        </tr>
        <tr>
            <td class="btnArea" colspan="2"><input type="submit" value="検索"/></td>
            <td class="btnArea" colspan="2"><button onclick="clearInput()">クリア</button></td>
        </tr>
    </table>
</form>

<a href="showMenu">メニューに戻る</a>
<h1><span>検索結果</span></h1>

<!-- ★検索結果が1件以上:表(部署ID・部署名)を th:each で繰り返す。変数は departmentFormList -->
<!-- ★0件:「検索結果はありません。」を表示 -->

</body>
<script type="text/javascript">
    function clearInput() {
        document.getElementById('idDepartment').value = '';
        // ★部署名の入力欄も空にする(id は th:field が自動で作る)
    }
</script>
</html>

ヒント:

  • th:if="${departmentFormList != null and departmentFormList.size() > 0}" で「1件以上」を判定できます(お手本参照)。
  • th:each="departmentForm : ${departmentFormList}" と th:text="${departmentForm.idDepartment}"。
  • th:field="*{idDepartment}" が何を作るかは、ブラウザで右クリック →「ページのソースを表示」で確認できます(id name value が作られる)。

考えるポイント(クリアボタン):<button> は type を省略すると送信ボタンになります。onclick で値を空にしたあとにフォームが送信されます。つまり「クリア=条件を消して全件検索」になります(テスト #35 は「クリア後に全件表示」を期待)。値を消すだけにしたい場合は type="button" を付けます。どちらがテスト仕様に合うか、自分で判断して理由をノートに書いてください。

ステップ 1-7:メニューにリンクを追加

menu.html の <table class="menuClass"> に、「社員登録」の行の次へ追加します。JavaScript の関数(selectDepartmentSubmit())は、すでにファイル下部にあります。

<tr>
    <td><a class="menuClass" href="javascript:selectDepartmentSubmit()"><i class="fas fa-search"></i> 部署検索 </a></td>
    <td class="description">登録済みの部署一覧表示と</br>更新、削除を行います。</td>
</tr>

なぜ <a href> で直接飛ばず、JavaScriptでフォームを作って送っているのか考えてください(Controller が POST 専用だから。リンクは GET で送られるので 405 になる)。

できたかの確認:再ビルド・再起動 → 管理者ログイン → メニュー →「部署検索」で全件が表示される。

ステップ 1-8:動作確認(単体テスト #1〜#8)

# 操作 期待
1 yamada@hoge.jp でログイン メニューに部署検索が出る
2 部署検索を押す 検索条件と全件の結果
3 右上のログアウト ログイン画面
4 クリア → 部署ID 10 → 検索 役員 のみ。入力した 10 が残る
5 クリア → 部署ID 1000 → 検索 検索結果はありません。+条件が残る
6 クリア → 部署名 営業 → 検索 営業企画部
7 クリア → 部署名 開発 → 検索 システム開発事業部
8 「メニューに戻る」 メニュー画面

※更新・削除ボタンは第2・3段階で追加するので、今は表示されなくて構いません(テスト #2 のボタン確認は後で実施)。

4.1 第1段階の振り返り(説明できればOK)

  • 画面→Controller→Service→Repository の流れを説明できる
  • 部分一致/完全一致をどのメソッドで実現し、なぜそれを選んだか説明できる
  • @ModelAttribute が何のためにあるか説明できる

5. 第2段階:削除

5.0 ゴールと完成の定義

検索結果の各行の「削除」→ 本当に削除しますか? → OK で削除。その部署に所属する社員が1人でもいれば削除できずエラー。削除後は一覧(全件)に戻る。 → 単体テスト #32〜#35。

ステップ 2-1:処理の手順をフローチャートにする

設計書「イベント処理・部署検索・削除」の⑥を読み、次を埋めてください。

削除ボタン押下(部署IDを送信)
 ├ 部署IDが無い/その部署が存在しない → ???(メッセージ)→ 一覧へ
 ├ その部署に所属する社員が1人以上   → ???(メッセージ)→ 一覧へ
 └ 削除する → ???(メッセージ)→ 一覧へ

考えるポイント:

  • DBに外部キー制約が無い(DDLに FOREIGN KEY が無い)ので、所属社員がいても DB は削除を止めてくれません。アプリ側で確認する必要があります。
  • 「存在しないID」はあり得ます(別の画面で削除済み、ブラウザの戻る→再送信 など)。

ステップ 2-2:削除サービスを作る

① インタフェース …/domain/service/DeleteDepartment.java

package com.example.rhizome.app.domain.service;

public interface DeleteDepartment {
    void deleteDepartment(Integer idDepartment);
}

② 実装 …/domain/service/impl/DeleteDepartmentImpl.java(お手本:DeleteEmployeeImpl)

package com.example.rhizome.app.domain.service.impl;

import com.example.rhizome.app.domain.service.DeleteDepartment;
import com.example.rhizome.app.infra.repository.DepartmentRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional
public class DeleteDepartmentImpl implements DeleteDepartment {

    DepartmentRepository departmentRepository;

    @Autowired
    public DeleteDepartmentImpl(DepartmentRepository departmentRepository) {
        this.departmentRepository = departmentRepository;
    }

    public void deleteDepartment(Integer idDepartment) {
        // ★考える:Repository のどのメソッドで「IDを指定して削除」できる?
        //          (お手本の DeleteEmployeeImpl を見る)
    }
}

注意:@Service を付け忘れると、Controller で使うとき起動時エラー(No qualifying bean…)になります。

ステップ 2-3:削除 Controller を作る

…/presentation/controller/DeleteDepartmentController.java(お手本:DeleteEmployeeController)

まず、定数を追加します。

// TransitionTargetPageNameKeyword の Department(Controller) の枠に追記
public static final String DELETE_DEPARTMENT = "/deleteDepartment";

骨格:

@Controller
public class DeleteDepartmentController {

    private final static Logger LOGGER = Logger.getLogger(DeleteDepartmentController.class.getName());
    private final static String SEVERE_ERROR_MESSAGE = "エラーが発生しました!";

    HttpSession httpSession;
    DeleteDepartment deleteDepartment;
    SearchDepartment searchDepartment;
    SearchEmployee searchEmployee;   // 所属社員の確認に使う(既存)

    @Autowired
    public DeleteDepartmentController(HttpSession httpSession, DeleteDepartment deleteDepartment,
                                      SearchDepartment searchDepartment, SearchEmployee searchEmployee) {
        this.httpSession = httpSession;
        this.deleteDepartment = deleteDepartment;
        this.searchDepartment = searchDepartment;
        this.searchEmployee = searchEmployee;
    }

    @RequestMapping(value = TransitionTargetPageNameKeyword.DELETE_DEPARTMENT, method = RequestMethod.POST)
    public String deleteDepartment(@RequestParam(value = "idDepartment", required = false) Integer idDepartment, Model model) {

        final String UNKNOWN_ID = "削除対象のIDが不明です。";
        final String HAS_EMPLOYEE = "1名以上が所属している部署は削除出来ません。";
        final String DELETE_COMPLETE = "削除しました。";

        try {
            // ★① idDepartment が null、または searchDepartment.searchBy(new SearchConditionDepartment(idDepartment, null)) が空
            //     → MESSAGE_ERROR に UNKNOWN_ID を載せて一覧へ
            // ★② searchEmployee.searchEmployeeByIdDepartment(idDepartment) が空でない
            //     → MESSAGE_ERROR に HAS_EMPLOYEE を載せて一覧へ
            // ★③ deleteDepartment.deleteDepartment(idDepartment) → MESSAGE_INFO に DELETE_COMPLETE を載せて一覧へ

            return "一覧へ戻る(ステップ 2-5 で決める)";
        } catch (Exception e) {
            httpSession.invalidate();
            e.printStackTrace();
            LOGGER.log(Level.SEVERE, SEVERE_ERROR_MESSAGE, e);
            return TransitionTargetPageNameKeyword.ERROR_HTML;
        }
    }
}

メッセージの載せ方(お手本のとおり):

model.addAttribute(PageReturnAttributeKeyword.MESSAGE_ERROR, new MessageForm(UNKNOWN_ID));

**確認の順序は「存在 → 所属社員 → 削除」**です(削除してから確認しても意味がありません)。

ステップ 2-4:「削除後にどの画面へ戻るか」を考える(重要)

課題:削除後は検索一覧(全件)を表示したい。でも、return "selectDepartment";(画面名で返す)だと画面が壊れます。なぜでしょう?

調べるヒント:

  • 検索画面の th:object="${searchConditionDepartmentForm}" は、どの Controller が用意している?
  • @ModelAttribute はどのクラスで有効だった?(ステップ 1-5)

→ そこで、検索 Controller に処理を引き継ぐ forward: を使います(お手本の DeleteEmployeeController を参照)。

return "forward:" + TransitionTargetPageNameKeyword.【どの定数?】;

ここに罠があります:forward: は元のリクエストのパラメータを引き継ぎます。削除ボタンは idDepartment=10 を送っているので、これを /selectDepartment に forward すると、「部署ID=10 で検索」が実行されてしまいます(実際に確認済みの動作です)。

解決方法は、お手本が SelectEmployeeController に**2つ目の select(引数に Form を持たない版)**を持っている理由と同じです。部署でも、パラメータを一切使わず全件を返す、もう1つの入口を作ります。

  1. 定数を追加:
    public static final String SELECT_DEPARTMENT_FORM_EMPTY = "/selectDepartmentAll";
    
  2. SelectDepartmentController に2つ目のメソッドを追加:
    @RequestMapping(value = TransitionTargetPageNameKeyword.SELECT_DEPARTMENT_FORM_EMPTY, method = RequestMethod.POST)
    public String select(Model model) {
        try {
            // ★全件取得 → DepartmentForm に変換 → DEPARTMENT_FORM_LIST で model へ
            return TransitionTargetPageNameKeyword.SELECT_DEPARTMENT_HTML;
        } catch (Exception e) {
            httpSession.invalidate();
            e.printStackTrace();
            LOGGER.log(Level.SEVERE, SEVERE_ERROR_MESSAGE, e);
            return TransitionTargetPageNameKeyword.ERROR_HTML;
        }
    }
    
  3. 削除 Controller の戻り先を forward:/selectDepartmentAll(定数 SELECT_DEPARTMENT_FORM_EMPTY を使う)にする。

なぜ forward: ならメッセージが画面に出るのか:model に入れた値は request の属性として公開され、forward でも同じリクエストなので残るためです。

ステップ 2-5:一覧にメッセージ表示と削除ボタンを足す

selectDepartment.html に追加します。

① 情報メッセージ(アラート):お手本の selectEmployee.html の次の部分を、同じように入れる(</h1> の「検索結果」見出しの後あたり)。

<th:block th:if="${messageInfo != null}">
    <script th:inline="javascript">
        alert([[${messageInfo.message}]]);
    </script>
</th:block>

② 結果テーブルに「操作」列と削除ボタン:

<th style="width: 150px;">操作</th>   <!-- thead に追加 -->

<td>                                    <!-- tbody の各行に追加 -->
    <form action="deleteDepartment" method="post" onsubmit="return confirm('本当に削除しますか?');"
          style="display: inline;">
        <input name="idDepartment" th:value="${departmentForm.idDepartment}" type="hidden">
        <input type="submit" value="削除">
    </form>
</td>

考えるポイント:

  • なぜ <a> ではなく <form method="post">?(削除のような「データを変える操作」を、URLを開くだけで実行される GET にしない)
  • hidden の name="idDepartment" と、Controller の @RequestParam("idDepartment") は一致している必要がある。

ステップ 2-6:動作確認

# 操作 期待
32 部署名テストで検索 → 対象の削除を押す(※テスト用の部署が無ければ、第4段階の後にやるか、DBに直接 INSERT INTO department (nm_department) VALUES ('テスト部署2'); を1行追加) 本当に削除しますか?
33 〃 → キャンセル 何も起きない
34 〃 → OK 削除され 削除しました。
35 クリア(全件)→ 役員 の削除 → OK 1名以上が所属している部署は削除出来ません。

追加で自分で確認(異常系):

  • ブラウザの開発者ツール(F12)で hidden の値を 999 に書き換えて削除 →「削除対象のIDが不明です。」
  • 削除直後にブラウザの「戻る」→ 再送信 → エラー画面にならず、メッセージが出る

5.1 第2段階の振り返り

  • なぜ所属社員の確認をアプリで行う必要があるか説明できる
  • なぜ戻り先を forward: の全件用エンドポイントにしたか(パラメータ引き継ぎ)説明できる
  • 削除を POST にする理由を説明できる

6. 第3段階:更新(提出可能ライン)

6.0 ゴールと完成の定義

一覧の「更新」→ 更新画面(部署IDは変更不可)→ 部署名を変更 →「本当によろしいですか?」→ 成功時 更新が完了しました。 で一覧へ。 → 単体テスト #9〜#20。

ステップ 3-1:画面と処理を分解する

[一覧]「更新」押下  ─ POST /showUpdateDepartment(idDepartment)
      ├ 対象が存在しない → 「更新対象のデータが見つかりません。」→ 一覧
      └ 存在する → [更新画面] に現在の値を表示(部署IDは変更不可)

[更新画面]「更新」押下 → 確認ダイアログ → POST /updateDepartmentResult
      ├ 部署名が空欄 → 「項目が空欄です。」→ 更新画面
      ├ 同じ名前の部署がある(自分と同じ名前=変更なし も含む)
      │      → 「既に同名の部署が存在するか、値が変更されていません。」→ 更新画面
      ├ 更新対象が存在しない → 「更新対象のデータが見つかりません。」→ 一覧
      └ 更新 → 「更新が完了しました。」(アラート) → 一覧

ステップ 3-2:定数を追加

// TransitionTargetPageNameKeyword
public static final String UPDATE_DEPARTMENT_INPUT_HTML = "updateDepartmentInput";   // HTML
public static final String SHOW_UPDATE_DEPARTMENT = "/showUpdateDepartment";         // Controller
public static final String UPDATE_DEPARTMENT_RESULT = "/updateDepartmentResult";

// PageReturnAttributeKeyword
public static final String UPDATE_DEPARTMENT_FORM = "updateDepartmentForm";

ステップ 3-3:更新 Form

…/presentation/form/UpdateDepartmentForm.java

package com.example.rhizome.app.presentation.form;

import com.example.rhizome.app.domain.model.Department;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class UpdateDepartmentForm {

    private Integer idDepartment;
    private String nmDepartment;

    /** Model(Department) → Form */
    public static UpdateDepartmentForm convertFrom(Department department) {
        return new UpdateDepartmentForm(department.getIdDepartment(), department.getNmDepartment());
    }

    /** Form → Model(Department) */
    public static Department convertTo(UpdateDepartmentForm form) {
        return new Department(form.getIdDepartment(), form.getNmDepartment());
    }

    /** 必須チェック。true:OK/false:部署ID か部署名が空 */
    public boolean hasRequiredValue() {
        // ★考える:idDepartment が null でなく、nmDepartment が null でも空白だけでもない
        return false;
    }
}

String の空チェックには isBlank()(空白だけも空扱い)が便利です。

ステップ 3-4:更新サービス

…/domain/service/UpdateDepartment.java

package com.example.rhizome.app.domain.service;

import com.example.rhizome.app.domain.model.Department;

public interface UpdateDepartment {
    void updateDepartment(Department department);
}

…/domain/service/impl/UpdateDepartmentImpl.java

@Service
@Transactional
public class UpdateDepartmentImpl implements UpdateDepartment {

    DepartmentRepository departmentRepository;

    @Autowired
    public UpdateDepartmentImpl(DepartmentRepository departmentRepository) {
        this.departmentRepository = departmentRepository;
    }

    public void updateDepartment(Department department) {
        // ★考える:Model を Entity に変換(DepartmentEntity.convertFrom)して、Repository の save に渡す
    }
}

import 文は削除サービスを参考に自分で書いてください(IDE の「インポートの整理」が使えます)。

重要な落とし穴:save は**IDがDBに無い(または null)だと更新ではなく新規追加(INSERT)**をします。後のステップで「存在確認してから呼ぶ」ようにします。

ステップ 3-5:更新画面を表示する Controller

…/presentation/controller/UpdateDepartmentController.java(お手本:UpdateEmployeeController)

@Controller
public class UpdateDepartmentController {

    // LOGGER、SEVERE_ERROR_MESSAGE、httpSession、searchDepartment、コンストラクタ は他と同じ作り

    @RequestMapping(value = TransitionTargetPageNameKeyword.SHOW_UPDATE_DEPARTMENT, method = RequestMethod.POST)
    public String showUpdateDepartment(@RequestParam("idDepartment") Integer idDepartment, Model model) {
        final String UPDATE_TARGET_ID_NOT_FOUND = "更新対象のデータが見つかりません。";

        try {
            // ★① searchDepartment.searchBy(new SearchConditionDepartment(idDepartment, null)) で対象を検索
            // ★② 結果が空なら MESSAGE_ERROR を載せて "forward:" + SELECT_DEPARTMENT_FORM_EMPTY
            // ★③ あれば UpdateDepartmentForm.convertFrom(結果の先頭) を
            //     UPDATE_DEPARTMENT_FORM という名前で model に載せ、UPDATE_DEPARTMENT_INPUT_HTML を返す

            return "…";
        } catch (Exception e) {
            // 他と同じ
        }
    }
}

ステップ 3-6:更新画面 HTML

updateDepartmentInput.html(お手本:updateEmployeeInput.html)

<!-- head・見出し・ログイン者名・ログアウトは他の画面と同じ。title は「部署情報更新」 -->
<form action="updateDepartmentResult"
      method="post"
      onsubmit="return confirm('本当によろしいですか?');"
      th:object="${updateDepartmentForm}">

    <div class="msgBox error" th:if="${messageError != null}">
        <span class="error" th:text="${messageError.message}"></span>
    </div>
    <table>
        <tr>
            <th class="header">部署ID</th>
            <td>
                <input class="readOnly" readonly="readonly" th:field="*{idDepartment}" type="text"/>
            </td>
        </tr>
        <tr>
            <th class="header">部署名</th>
            <td>
                <input maxlength="50" placeholder="部署名" required="required"
                       th:field="*{nmDepartment}" type="text"/>
            </td>
        </tr>
        <tr>
            <td class="btnArea" colspan="2"><input type="submit" value="更新"/></td>
        </tr>
    </table>
</form>
<a href="javascript:selectDepartmentSubmit()">一覧に戻る</a>
<script type="text/javascript">
    function selectDepartmentSubmit() {
        const form = document.createElement('form');
        form.setAttribute('method', 'post');
        form.setAttribute('action', 'selectDepartment');
        document.body.appendChild(form);
        form.submit();
    }
</script>

考えるポイント:

  • readonly と disabled の違い:readonly は編集不可だが送信される。disabled は送信されない。部署IDを disabled にすると ID が null で届き、更新が新規追加になってしまいます。開発者ツール(F12)の Network タブで、実際に送られる値を比べてみてください。
  • 「一覧に戻る」は、検索の Controller が POST 専用なのでメニューと同じ方法(JavaScriptでフォームを作って送信)にしています。
  • action は updateDepartmentResult(先頭に / を付けない相対パス)。

ステップ 3-7:更新を実行する Controller(最大の山場)

…/presentation/controller/UpdateDepartmentResultController.java(お手本:UpdateEmployeeResultController)

@Controller
public class UpdateDepartmentResultController {

    // LOGGER、SEVERE_ERROR_MESSAGE、httpSession、
    // searchDepartment(SearchDepartment)、updateDepartment(UpdateDepartment) とコンストラクタ

    @RequestMapping(value = TransitionTargetPageNameKeyword.UPDATE_DEPARTMENT_RESULT, method = RequestMethod.POST)
    public String departmentUpdateResult(@ModelAttribute UpdateDepartmentForm updateDepartmentForm, Model model) {

        final String NOTHING = "項目が空欄です。";
        final String DUPLICATION = "既に同名の部署が存在するか、値が変更されていません。";
        final String NO_TARGET = "更新対象のデータが見つかりません。";
        final String UPDATE_COMPLETE = "更新が完了しました。";

        try {
            // ★ここに「チェック→更新」を、下の【チェックの順序】どおりに書く
            return "…";
        } catch (Exception e) {
            // 他と同じ
        }
    }
}

【チェックの順序】(順番が大切です。理由も考えてください)

順 チェック NGの場合 戻り先
1 必須(hasRequiredValue()) NOTHING 更新画面(UPDATE_DEPARTMENT_INPUT_HTML)
2 更新後の部署名が既に存在する(searchBy(new SearchConditionDepartment(null, 更新後の部署名)) が空でない) DUPLICATION 更新画面
3 更新対象IDが存在する(searchBy(new SearchConditionDepartment(ID, null)) が空でない) NO_TARGET 一覧(forward)
4 更新を実行 → MESSAGE_INFO に UPDATE_COMPLETE 一覧(forward)

なぜ必須チェックが先か:部署名が空のまま「同名検索」を行うと、条件が無視されて全件ヒットしてしまい、「同名の部署が存在する」と誤判定されるからです。(設計書は重複確認→必須確認の順に書かれていますが、この順で実装すると上記の問題が出ます。)

「変更なし」も重複エラーになる理由:変更しない(自分と同じ名前)で検索すると、自分自身がヒットします。そのため、同名あり・変更なしを1つの判定でまとめて捕まえられます(設計書のメッセージが1つにまとまっている理由)。

エラー時に更新画面へ戻すときは、入力済みの値を残すために、次の2つを載せます:

model.addAttribute(PageReturnAttributeKeyword.MESSAGE_ERROR, new MessageForm(DUPLICATION));
model.addAttribute(PageReturnAttributeKeyword.UPDATE_DEPARTMENT_FORM, updateDepartmentForm);
return TransitionTargetPageNameKeyword.UPDATE_DEPARTMENT_INPUT_HTML;

ブラウザの required があるのに、サーバー側でも必須チェックする理由:ブラウザの検証は、開発者ツールで簡単に回避できるためです。

ステップ 3-8:一覧に「更新」ボタンを追加

selectDepartment.html の操作列(削除ボタンと同じ <td> の中)に追加:

<form action="showUpdateDepartment" method="post" style="display: inline;">
    <input name="idDepartment" th:value="${departmentForm.idDepartment}" type="hidden">
    <input type="submit" value="更新">
</form>

ステップ 3-9:動作確認(単体テスト #9〜#20)

# 操作 期待
9 部署ID 10 の「更新」 部署ID変更不可で 10/役員
10 何も変えずに更新 → OK 既に同名の部署が存在するか、値が変更されていません。
12 部署名を 役員改 にして更新 → OK 更新が完了しました。 → 一覧に反映
14 部署名を 人事・総務部 にして更新 → OK 同名エラー
15 「一覧に戻る」 変更されていない一覧
17 部署名を空欄で更新 ブラウザの「このフィールドを入力してください。」(画面遷移なし)
18 「一覧に戻る」 検索画面
19 更新画面で右上のログアウト ログイン画面
20 部署名を 役員 に戻して更新 更新が完了しました。

追加で自分で確認(異常系):

  • F12 で required を削除して空欄のまま送信 →「項目が空欄です。」
  • F12 で ID を 999 に書き換えて送信 →「更新対象のデータが見つかりません。」(DBに新しい行が増えていないこと)

6.1 結合テスト(部署更新)

結合テスト仕様書の「ログインから部署検索、部署更新まで」:部署名を 役員 → 役員改 に変更し、社員検索画面で、もともと 役員 だった社員の所属部署が 役員改 と表示される。(社員テーブルは部署名ではなく部署IDだけを持ち、表示するときに部署テーブルと結合しているためです。) ※手順3の「社員ID 10」は「部署ID 10」の誤記と思われます。

6.2 ここまでが提出可能ライン — 振り返り

  • 単体テスト #1〜#20、#32〜#35 が期待どおり
  • 更新の検証順序の理由を説明できる
  • save が新規追加になる条件を説明できる
  • readonly と disabled の違いを説明できる

7. 第4段階:新規登録(チャレンジ)

7.0 ゴールと完成の定義

メニュー「部署登録」→ 入力 → 登録確認 → 登録 → 完了画面(部署登録が完了しました)。確認画面の「登録内容の編集」で、値を保ったまま入力画面に戻れる。 → 単体テスト #21〜#31、結合テストの登録シナリオ。

画面フロー:

[入力] 部署ID(自動で設定されます)/部署名 ──「登録確認」──▶ POST /insertDepartmentConfirm
[確認] すべて変更不可 ──「登録」──▶ POST /insertDepartment ──▶ [完了]
                        ──「登録内容の編集」──▶ POST /insertDepartmentInput(値を保持)
[完了]「続けて登録する」──▶ POST /insertDepartmentInput(空)/「メニューに戻る」

ステップ 4-1:定数を追加

// TransitionTargetPageNameKeyword
public static final String INSERT_DEPARTMENT_INPUT_HTML = "insertDepartmentInput";
public static final String INSERT_DEPARTMENT_CONFIRM_HTML = "insertDepartmentConfirm";
public static final String INSERT_DEPARTMENT_RESULT_HTML = "insertDepartmentResult";
public static final String INSERT_DEPARTMENT_INPUT = "/insertDepartmentInput";
public static final String INSERT_DEPARTMENT_CONFIRM = "/insertDepartmentConfirm";
public static final String INSERT_DEPARTMENT = "/insertDepartment";

// PageReturnAttributeKeyword
public static final String INSERT_DEPARTMENT_FORM = "insertDepartmentForm";

ステップ 4-2:登録 Form

…/presentation/form/InsertDepartmentForm.java

@Data
@AllArgsConstructor
@NoArgsConstructor
public class InsertDepartmentForm {

    private String nmDepartment;

    /** Form → Model。部署IDは自動採番なので null を渡す */
    public static Department convertTo(InsertDepartmentForm form) {
        return new Department(null, form.getNmDepartment());
    }

    /** 入力チェック。エラーのあった項目名のリストを返す(なければ空のリスト) */
    public List<String> validateParameter() {
        List<String> error = new ArrayList<>();
        // ★部署名が null または空白だけなら error.add("部署名");
        return error;
    }
}

InsertEmployeeForm.validateParameter() が書き方のお手本です(java.util.List と ArrayList の import も忘れずに)。

ステップ 4-3:登録サービス(起動エラーを体験・解決する)

RegisterDepartment(インタフェース)はすでにありますが、実装クラスがありません。この状態で Controller に使うと、アプリの起動に失敗し、次のようなエラーが出ます。

No qualifying bean of type '…RegisterDepartment' available

考える:このエラーは何を伝えている?(Spring が RegisterDepartment を実装した @Service を探したが無かった)

…/domain/service/impl/RegisterDepartmentImpl.java(お手本:RegisterEmployeeImpl)

@Service
@Transactional
public class RegisterDepartmentImpl implements RegisterDepartment {

    DepartmentRepository departmentRepository;

    @Autowired
    public RegisterDepartmentImpl(DepartmentRepository departmentRepository) {
        this.departmentRepository = departmentRepository;
    }

    public void registerDepartment(Department department) {
        // ★考える:Entity に変換して save。ID が null のとき save は何をする?(自動採番して追加)
    }
}

ステップ 4-4:3つの Controller

共通の約束:@ModelAttribute で空の InsertDepartmentForm を用意し(insertDepartmentForm() メソッド)、各メソッドの引数に InsertDepartmentForm を取ります。

① InsertDepartmentInputController(/insertDepartmentInput)

@RequestMapping(value = TransitionTargetPageNameKeyword.INSERT_DEPARTMENT_INPUT, method = RequestMethod.POST)
public String insertInput(InsertDepartmentForm insertDepartmentForm, Model model) {
    model.addAttribute(PageReturnAttributeKeyword.INSERT_DEPARTMENT_FORM, insertDepartmentForm);
    return TransitionTargetPageNameKeyword.INSERT_DEPARTMENT_INPUT_HTML;
}

なぜ引数の Form を model に載せ直す?(確認画面の「登録内容の編集」から戻ったとき、入力済みの値を残すため。メニューから来たときは空の Form)

② InsertDepartmentConfirmController(/insertDepartmentConfirm)

順 チェック NGのとき
1 insertDepartmentForm.validateParameter() が空でない メッセージ String.join(", ", error) + "が不正です。"(=部署名が不正です。)→ 入力画面
2 同名の部署がある(searchBy(new SearchConditionDepartment(null, 部署名)) が空でない) 既に同名の部署が存在します。 → 入力画面
3 OK 確認画面 INSERT_DEPARTMENT_CONFIRM_HTML

エラー時も OK 時も、INSERT_DEPARTMENT_FORM で Form を model に載せます(値を画面に残すため)。コンストラクタは HttpSession と SearchDepartment を受け取ります。

③ InsertDepartmentController(/insertDepartment)

①と同じチェック(入力・重複)をもう一度行い、問題なければ registerDepartment.registerDepartment(InsertDepartmentForm.convertTo(form)) を呼んで、INSERT_DEPARTMENT_RESULT_HTML を返します。コンストラクタは HttpSession、RegisterDepartment、SearchDepartment。

考える:確認画面でチェック済みなのに、なぜ登録時に再チェック?(確認画面を通らずに直接送られる/確認と登録の間に別の人が同名を登録することがある)。完了画面でブラウザを更新(F5)すると、もう一度送信されます。再チェックが無いと二重登録になってしまいます。

ステップ 4-5:3つの HTML

お手本の insertEmployeeInput.html / insertEmployeeConfirm.html / insertEmployeeResult.html を見ながら作ります。部署に不要な項目(社員名・パスワード・権限・所属部署など)はすべて取り除いてください。

insertDepartmentInput.html(要点)

<form action="insertDepartmentConfirm" method="post" th:object="${insertDepartmentForm}">
    <!-- エラーメッセージ表示(他の画面と同じ) -->
    <table>
        <tr>
            <th class="header">部署ID</th>
            <td><input class="readOnly" readonly="readonly" type="text" value="自動で設定されます"/></td>
        </tr>
        <tr>
            <th class="header">部署名</th>
            <td><input maxlength="50" placeholder="部署名" required="required"
                       th:field="*{nmDepartment}" type="text"/></td>
        </tr>
        <tr><td class="btnArea" colspan="2"><input type="submit" value="登録確認"/></td></tr>
    </table>
</form>
<a href="showMenu">メニューに戻る</a>

insertDepartmentConfirm.html(要点)

<form action="insertDepartment" id="insertForm" method="post" th:object="${insertDepartmentForm}">
    <table>
        <tr>
            <th class="header">部署ID</th>
            <td><input class="readOnly" readonly="readonly" type="text" value="自動で設定されます"/></td>
        </tr>
        <tr>
            <th class="header">部署名</th>
            <td><input class="readOnly" name="nmDepartment" readonly="readonly"
                       th:value="*{nmDepartment}" type="text"/></td>
        </tr>
        <tr><td class="btnArea" colspan="2"><input type="submit" value="登録"/></td></tr>
    </table>
    <a href="javascript:onClickEdit()">登録内容の編集</a> | <a href="showMenu">メニューに戻る</a>
</form>
<script type="text/javascript">
    function onClickEdit() {
        const form = document.getElementById('insertForm');
        form.setAttribute('method', 'post');
        form.setAttribute('action', 'insertDepartmentInput');
        form.submit();
    }
</script>

name="nmDepartment" が付いているので、画面の値がそのまま次の POST で送られます。「登録内容の編集」は、同じフォームの送信先だけを insertDepartmentInput に切り替えているので、値が入力画面に戻ります。

insertDepartmentResult.html(要点)

<h2>部署登録が完了しました</h2>
<a href="javascript:insertSubmit()">続けて登録する</a> | <a href="showMenu">メニューに戻る</a>
<script type="text/javascript">
    function insertSubmit() {
        const form = document.createElement('form');
        form.setAttribute('method', 'post');
        form.setAttribute('action', 'insertDepartmentInput');
        document.body.appendChild(form);
        form.submit();
    }
</script>

(<head> は他の画面と同じ内容にしてください。)

ステップ 4-6:メニューにリンク追加

<tr>
    <td><a class="menuClass" href="javascript:insertDepartmentSubmit()"><i class="fas fa-plus"></i> 部署登録 </a></td>
    <td class="description">新規で部署追加を行います。</td>
</tr>

ステップ 4-7:部署名の桁数を確認する(重要)

実験:部署名に 11文字以上(例:ABCDEFGHIJK)を入れて登録してみてください。

  • 期待されるのは、設計書の「1〜50文字」で登録できること。
  • しかし、DDL は nm_department VARCHAR(10) のため、MySQL のエラー(Data too long for column 'nm_department')になり、エラー画面に移動してログアウト状態になります(Controller の catch がセッションを破棄するため)。

これは設計書と実際のDBの食い違いです。あなたの実装のミスではありません。README の「問題が出そうな場合は、必ずルールにしたがって適切な相手に相談してください」に従い、講師に報告して判断を仰いでください。報告のしかた(例):

「部署名に11文字以上を入れると登録でDBエラーになります。テーブル定義書は varchar(50)、DDL は VARCHAR(10) です。再現手順は…、ログの該当行は…。どちらに合わせればよいでしょうか」

(講師から DDL の変更や maxlength の指示があれば、それに従ってください。)

ステップ 4-8:動作確認(単体テスト #21〜#31)

# 操作 期待
21 メニュー「部署登録」 部署IDは「自動で設定されます」、部署名は空欄
22, 23 メニューに戻る/ログアウト それぞれ遷移する
24 テスト部署1 → 登録確認 確認画面に表示される
25 「登録内容の編集」 入力画面に テスト部署1 が残っている
26 役員 → 登録確認 既に同名の部署が存在します。
27 確認画面で「メニューに戻る」 メニュー画面
28 テスト部署1 → 登録確認 →「登録」 部署登録が完了しました
29 「続けて登録する」 入力画面(空)
30 テスト部署1 → 登録確認 同名エラー
31 テスト部署2 を登録 → 完了 → メニューに戻る 登録できる

7.1 結合テスト

結合テスト仕様書の部署関連シナリオ(部署の削除と登録、部署を使った社員登録、社員の異動後の部署削除)を実施します。 ※結合テスト1件目の「sugi@hoge.jp でログイン」は、sugi が一般ユーザーのため社員登録ができません。管理者(yamada@hoge.jp)で実施してください。

7.2 第4段階の振り返り

  • 確認画面から「登録内容の編集」で値が戻る仕組みを説明できる
  • 登録時に再チェックする理由を説明できる
  • 起動エラー(No qualifying bean)の意味を説明できる

8. 第5段階:アクセス制御アノテーション(チャレンジその2)

8.0 ゴール

第1〜4段階のすべての Controller メソッドに既存の @SessionCheck と @PermissionCheck を付け、未ログインや一般ユーザーが部署画面に入れないようにします。

ステップ 5-1:既存の仕組みを読み解く(README:機能を解析する)

次の順に読み、それぞれ自分の言葉でノートに説明を書いてください。

  1. aop/aspect/SessionCheck.java と PermissionCheck.java:アノテーションの定義(@Target(METHOD)、@Retention(RUNTIME) の意味)
  2. aop/aspect/SessionCheckAspect.java:@Pointcut、@Before。ログイン情報がセッションに無いとどうなる?
  3. aop/aspect/PermissionCheckAspect.java:リクエストのURLを何と比べている?(TransitionTargetPageNameKeyword の getAdminPageList() / getUserPageList())
  4. aop/Handler/GlobalExceptionHandler.java:投げられた例外を受けて、どの画面に何を表示している?
  5. 既存の MenuController:アノテーションがどこに付いている?

README の注意「アノテーションをコピペしない」 の意味:アノテーションのクラス自体を別の場所にコピーして使うと、チェックが一切動作しません(エラーも出ません)。SessionCheckAspect の @Pointcut は「com.example.rhizome.aop.aspect.SessionCheck というクラスのアノテーション」を指定しているためです。必ず既存のものを import して使ってください。

ステップ 5-2:付与する

全 Controller の @RequestMapping メソッドに、お手本(社員機能)と同じように2つを付けます(検索の2つの select メソッドも両方)。

import com.example.rhizome.aop.aspect.PermissionCheck;
import com.example.rhizome.aop.aspect.SessionCheck;

    @PermissionCheck
    @SessionCheck
    @RequestMapping(value = …, method = RequestMethod.POST)
    public String …

ステップ 5-3:URLを許可リストに登録する

付けた直後に管理者で操作してみてください。ログイン画面に戻され「不正な遷移です。」と表示されるはずです。

原因を自分で調べる:コンソールのどの例外?(InsufficientPermissionException)それを投げているのは?(PermissionCheckAspect)何と何を比較している?

対処:TransitionTargetPageNameKeyword.getAdminPageList() の Set.of(…) に、部署の Controller の URL をすべて追加します。

SELECT_DEPARTMENT,
SELECT_DEPARTMENT_FORM_EMPTY,
DELETE_DEPARTMENT,
SHOW_UPDATE_DEPARTMENT,
UPDATE_DEPARTMENT_RESULT,
INSERT_DEPARTMENT_INPUT,
INSERT_DEPARTMENT_CONFIRM,
INSERT_DEPARTMENT

特に重要:forward: で到達する SELECT_DEPARTMENT_FORM_EMPTY(/selectDepartmentAll)も必要です。forward: で呼ばれた先でも権限チェックが動き、そのときのURLは forward 先のURL になるためです。登録を忘れると、削除や更新はDBに反映されるのに、画面には「不正な遷移です。」が出ます。

ステップ 5-4:3パターンで確認する

パターン 操作 期待
未ログイン ログインせずに POST /selectDepartment を送る ログイン画面+不正な遷移です。
一般ユーザー jtaka@hoge.jp/bbb でログインし、POST /selectDepartment を送る 同上
管理者 yamada@hoge.jp で部署の全機能を操作 すべて正常

POST を直接送る方法(Windows は curl.exe):

curl -s -c cj.txt -b cj.txt -X POST http://localhost:8080/doLogin -d "mailAddress=jtaka@hoge.jp&password=bbb"
curl -s -c cj.txt -b cj.txt -X POST http://localhost:8080/selectDepartment

対照実験:いったんアノテーションを外すと、一般ユーザーでも部署画面が見えてしまうことを確認し、付けるとブロックされることを確認してください。「メニューにリンクを出さなければ十分」ではない理由を説明できるようにします(URLを直接送れば画面に入れてしまうため)。

8.1 第5段階の振り返り

  • AOP の仕組み(アノテーション → Aspect → 例外 → ControllerAdvice)を図で説明できる
  • 許可リストに forward 先のURLも必要な理由を説明できる
  • 3パターンの確認結果を記録した

9. テストの進め方(テスト仕様書の使い方)

  1. テスト仕様書/単体テスト仕様書.xlsx の「部署管理テスト仕様書」を、各段階の終わりに、該当する項番を上から順に実施します。
    • 検索:#1〜#8/削除:#32〜#35/更新:#9〜#20/登録:#21〜#31
  2. 実施日と結果(OK/NG)、気づいたことを備考欄に記入します。
  3. NG は原因を調べて修正し、最初からやり直します(一部だけ直して終わりにしない)。
  4. テスト順の注意:#10・#12・#14・#17・#20 は、前の項番の結果(部署名が 役員改 になっている等)を前提にしています。上から順に実施してください。やり直すときはDBを初期状態に戻します(§1.2)。
  5. テスト仕様書 の「表示データ」シートは社員用のデータで、部署の期待値がそのまま書かれていません。期待される結果は、§1.2 の初期データとテスト項目の文言から判断します(分からなければ質問してください)。

Maven のビルド(mvn install)では、プロジェクトの設定上、JUnit のテストは実行されません。テスト仕様書の確認は画面を操作する手動テストで行います。


10. 詰まったとき

10.1 エラーの読み方(5ステップ)

  1. 症状を言葉にする:何をして、どうなった?
  2. Eclipse のコンソールを開き、赤い文字のうち、一番下の Caused by: の行を探す。
  3. 自分のクラス名(com.example.rhizome…)が出ている最初の行を見る(その行番号が原因の手がかり)。
  4. ブラウザの F12 → Network タブで、送信先URLとパラメータを確認する。
  5. Controller の先頭にブレークポイントを置いてデバッグ実行し、値を確認する。

Controller の catch はすべて、セッションを破棄してエラー画面を出す作りです。画面が「エラー」になったら、コンソールのスタックトレースを必ず見てください。

10.2 症状別の早見表

症状 考えられる原因 確認すること
ビルドで「シンボルを見つけられません getXxx()」が大量に出る JDK が17ではない/Lombok が効いていない java -version、Eclipse の JRE 設定
起動時 Table 'rezodb.employee' doesn't exist DDLを実行していない DB のテーブルを確認
起動時 Access denied / Unknown database DB・ユーザー未作成 §1.2、application.properties
起動時 NoSuchBeanDefinitionException / UnsatisfiedDependencyException @Service の付け忘れ/実装クラスが無い/パッケージの置き場所 例外の最後の Caused by に書かれた型
画面が エラー になる Controller 内で例外が発生(握りつぶされている) コンソールの Caused by
画面に「不正なリクエストです。」+ 405 POSTのURLを GET で呼んだ(<a href> で直接遷移した等) F12 Network のメソッド
404 Not Found URLの綴り違い/先頭の / の有無 Network のURLと @RequestMapping の値を並べる
Error resolving template [xxx] 返した画面名と templates のファイル名が不一致 戻り値の文字列とファイル名
Neither BindingResult nor plain target object for bean name 'xxxForm' th:object の Form が model に無い(@ModelAttribute が無い/別の Controller から画面名で返した) その Form を誰が model に載せているか
Exception evaluating SpringEL expression HTML 内の項目名の綴り違い Form のフィールド名と *{…}
検索で条件が効かない・全件になる name 属性とFormのフィールド名の不一致 HTML の th:field と Form
削除・更新のあと、1件しか表示されない forward: で元のパラメータが検索条件に使われた 戻り先が全件用のURLか
更新したのに行が増えた IDが送られていない(disabledにした等)/ID null の save は追加 F12 Network の送信値、DBの行
管理者なのに「不正な遷移です。」 許可リストにURL(特に forward 先)が無い §8 ステップ 5-3
Data too long for column 'nm_department' DDLが VARCHAR(10) §7 ステップ 4-7(講師に報告)
クリアを押したら検索が走る <button> は type 省略で送信ボタン §4 ステップ 1-6
日本語が文字化け ファイルの文字コードが UTF-8 ではない Eclipse のエンコーディング設定
ポート8080が使用中 前回起動したプロセスが残っている 停止してから再起動

10.3 相談の型(質問は次の4点をそろえて)

  1. やりたいこと(設計書のどの項目か)
  2. 期待した結果/実際の結果(画面の状態、エラーの該当ログ)
  3. 試したこと(調べたキーワード、変更した内容)
  4. 自分の考え(原因はこうだと思う、理由は…)

11. 提出前チェックリスト

コード

  • ビルドが通り、起動できる
  • 画面名・URL・modelのキーが定数になっている(文字列の直書きがない)
  • 社員機能からの不要な処理(パスワード、部署プルダウン等)が残っていない
  • 使っていない import・コメントアウトしたコード・デバッグ用の出力が残っていない
  • メッセージの文言が設計書・テスト仕様書と一字一句同じ
  • 既存のファイルを不要に書き換えていない(変更は定数・メニューへの追記のみ)

テスト

  • 単体テスト仕様書(部署管理)の実施日・結果を記入(第3段階までは必須)
  • 異常系を自分で追加確認した(存在しないID、required を外した送信 など)
  • 結合テスト仕様書(部署関連)を実施できた範囲で記入

理解(口頭で説明できること)

  • 画面からDBまでのリクエストの流れ
  • forward: と全件用エンドポイントを使う理由
  • 更新の検証順序の理由
  • (第5段階まで行った人)AOPの仕組みと許可リストの役割

気づいたことのメモ(加点対象)

  • 設計書の誤記や、設計書とDBの食い違い(桁数など)を報告した

合格ライン:第3段階まで(検索・削除・更新)とテストの実施記録。第4・5段階はチャレンジです。