Spring BootとVue.jsを活用した地域医療管理プラットフォームの構築

システム開発の背景と設計方針

地域医療現場における情報管理は、従来は紙媒体や分散型の電子表計算に依存しており、データの参照遅延、物理的保管スペースの制約、および情報漏洩リスクが長年の課題となっていました。現代のWeb技術とクラウド基盤を活用することで、診療録の即時反映、患者ヘルスデータの一元管理、薬剤在庫の可視化を実現し、医療スタッフの業務負荷軽減と情報処理の正確性向上を図ります。本プロジェクトでは、バックエンドにSpring Boot、フロントエンドにVue.jsを採用し、MySQLによる堅牢なデータ格納基盤を構築しています。

実行環境と技術スタック

  • 言語・ランタイム: Java 8, Spring Boot
  • Webサーバ: Apache Tomcat 7
  • ストレージ: MySQL 5.7 (InnoDBエンジン)
  • ビルドツール: Apache Maven 3.3.9
  • 開発IDE: IntelliJ IDEA / Eclipse
  • データベース管理: Navicat for MySQL
  • クライアントブラウザ: Google Chrome

アクセスパスは管理画面と利用者画面で分離されており、認証トークンに基づきリソースへのアクセス制御を行います。システム起動後、`http://localhost:8080/{project}/admin/dist/index.html`で管理者ポータル、`http://localhost:8080/{project}/front/dist/index.html`で利用者ポータルにアクセス可能です。

基盤技術の選定理由

Java言語は、厳格な型システムと豊富な標準ライブラリが特徴です。メモリ管理の自動化(ガベージコレクション)と例外ハンドリング機構により、医療系システムで求められる高い稼働率とエラー耐性を確保できます。オブジェクト指向設計によるカプセル化とポリモーフィズムを活用し、ドメインロジックとプレゼンテーション層を明確に分離します。

Spring Bootは、従来のSpringフレームワークにおける膨大なXML設定を自動構成(Auto-Configuration)に置き換えることで、開発初期段階のハードルを大幅に低減しました。依存性注入(DI)とアスペクト指向プログラミング(AOP)を統合し、トランザクション管理やセキュリティフィルタを宣言的に実装可能にしています。また、内蔵された依存関係解決機構により、ライブラリバージョンの競合を回避し、プロジェクトの安定性を担保します。

MySQL 5.7は、成熟した関係型データベースとして、ACID準拠のトランザクション処理と高度な索引最適化を提供します。患者情報や処方箋データのような構造化されたデータに対して、効率的な検索と整合性の高い保存を実現します。データ冗長性の低減と水平展開の容易さから、中規模以上のWebアプリケーションのバックエンドストレージとして標準的に採用されています。

コア機能の実装とコード解説

システムでは、ファイルリソースのアップロード・配信機能と、医療スタッフ間の情報交換掲示板が主要なコンポーネントとして実装されています。以下に、保守性と拡張性を考慮しリファクタリングされた実装例を示します。

メディアリソース管理コンポーネント

package com.medical.resource;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.UUID;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ResourceUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import com.medical.annotation.PublicAccess;
import com.medical.common.ApiResponse;
import com.medical.config.AppSetting;
import com.medical.repository.ConfigRepository;
import com.baomidou.mybatisplus.mapper.EntityWrapper;

@RestController
@RequestMapping("/media")
public class MediaAssetController {

    private final ConfigRepository settingRepo;
    private final String rootStoragePath;

    public MediaAssetController(ConfigRepository settingRepo, 
                                 @Value("${app.storage.root:classpath:static}") String basePath) {
        this.settingRepo = settingRepo;
        this.rootStoragePath = basePath;
    }

    @PostMapping("/upload")
    @PublicAccess
    public ApiResponse storeMedia(@RequestParam("source") MultipartFile mediaFile,
                                  @RequestParam(value = "category", required = false) String category) throws IOException {
        if (mediaFile.isEmpty()) {
            return ApiResponse.error("入力データが存在しません");
        }

        String originalName = mediaFile.getOriginalFilename();
        String extension = originalName.contains(".") ? originalName.substring(originalName.lastIndexOf(".")) : ".bin";
        String generatedKey = UUID.randomUUID().toString().replace("-", "") + "_" + 
                              LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")) + extension;

        File baseDir = new File(ResourceUtils.getURL(this.rootStoragePath).getPath());
        if (!baseDir.exists()) baseDir = new File("");
        
        File uploadDir = new File(baseDir.getAbsolutePath(), "/assets/");
        if (!uploadDir.exists()) uploadDir.mkdirs();

        File targetFile = new File(uploadDir.getAbsolutePath() + File.separator + generatedKey);
        mediaFile.transferTo(targetFile);

        if ("system_avatar".equals(category)) {
            EntityWrapper<AppSetting> queryWrapper = new EntityWrapper<>();
            queryWrapper.eq("param_name", "platform_icon");
            AppSetting existing = settingRepo.selectOne(queryWrapper);
            
            if (existing == null) {
                existing = new AppSetting();
                existing.setParamName("platform_icon");
            }
            existing.setParamValue(generatedKey);
            settingRepo.insertOrUpdate(existing);
        }

        return ApiResponse.success().put("asset_key", generatedKey);
    }

    @PostMapping("/download")
    @PublicAccess
    public ResponseEntity retrieveMedia(@RequestParam String key) {
        try {
            File baseDir = new File(ResourceUtils.getURL(this.rootStoragePath).getPath());
            if (!baseDir.exists()) baseDir = new File("");
            File uploadDir = new File(baseDir.getAbsolutePath(), "/assets/");
            if (!uploadDir.exists()) uploadDir.mkdirs();

            File targetFile = new File(uploadDir.getAbsolutePath() + File.separator + key);
            if (targetFile.exists()) {
                HttpHeaders headers = new HttpHeaders();
                headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
                headers.setContentDispositionFormData("attachment", key);
                return new ResponseEntity<>(Files.readAllBytes(targetFile.toPath()), headers, org.springframework.http.HttpStatus.OK);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return ResponseEntity.status(org.springframework.http.HttpStatus.NOT_FOUND).build();
    }
}

掲示板ドメインとトランザクション制御

package com.medical.board;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Map;

import javax.servlet.http.HttpServletRequest;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.medical.annotation.PublicAccess;
import com.medical.common.PageResult;
import com.medical.common.QueryHelper;
import com.medical.entity.BoardPost;
import com.medical.entity.vo.BoardPostVO;
import com.medical.repository.BoardRepository;
import com.medical.response.JsonResult;
import com.baomidou.mybatisplus.mapper.EntityWrapper;
import com.baomidou.mybatisplus.mapper.Wrapper;

@RestController
@RequestMapping("/discussions")
public class CommunityBoardController {

    @Autowired
    private BoardRepository postRepository;

    @GetMapping("/admin/dashboard")
    public JsonResult fetchAdminPosts(@RequestParam Map filters, BoardPost criteria, HttpServletRequest req) {
        String role = (String) req.getSession().getAttribute("user_role");
        if (!"SUPER_ADMIN".equals(role)) {
            Long authorId = (Long) req.getSession().getAttribute("current_user_id");
            criteria.setAuthorId(authorId);
        }
        EntityWrapper<BoardPost> queryBuilder = new EntityWrapper<>();
        PageResult paginated = postRepository.paginate(filters, QueryHelper.likeOrEqual(queryBuilder, criteria));
        return JsonResult.success().data(paginated);
    }

    @GetMapping("/public/feed")
    @PublicAccess
    public JsonResult fetchPublicFeed(@RequestParam Map filters, BoardPost criteria) {
        EntityWrapper<BoardPost> queryBuilder = new EntityWrapper<>();
        PageResult paginated = postRepository.paginate(filters, QueryHelper.likeOrEqual(queryBuilder, criteria));
        return JsonResult.success().data(paginated);
    }

    @GetMapping("/details/{id}")
    public JsonResult getPostDetails(@PathVariable Long id) {
        BoardPostVO record = postRepository.selectDetailView(id);
        return record != null ? JsonResult.success().data(record) : JsonResult.notFound();
    }

    @GetMapping("/tree/{rootId}")
    @PublicAccess
    public JsonResult getThreadTree(@PathVariable String rootId) {
        BoardPost root = postRepository.selectById(Long.parseLong(rootId));
        if (root != null) {
            resolveNestedReplies(root);
        }
        return JsonResult.success().data(root);
    }

    private void resolveNestedReplies(BoardPost parent) {
        EntityWrapper<BoardPost> childWrapper = new EntityWrapper<>();
        childWrapper.eq("parent_ref_id", parent.getId());
        List<BoardPost> children = postRepository.selectList(childWrapper);
        
        if (children == null || children.isEmpty()) return;
        
        parent.setReplies(children);
        for (BoardPost child : children) {
            resolveNestedReplies(child);
        }
    }

    @PostMapping("/create")
    public JsonResult publishPost(@RequestBody BoardPost newPost, HttpServletRequest req) {
        newPost.setId(generateDistributedId());
        Long userId = (Long) req.getSession().getAttribute("current_user_id");
        newPost.setAuthorId(userId);
        postRepository.insert(newPost);
        return JsonResult.success();
    }

    @PostMapping("/edit")
    @Transactional
    public JsonResult modifyPost(@RequestBody BoardPost updatedPost) {
        postRepository.updateById(updatedPost);
        return JsonResult.success();
    }

    @PostMapping("/remove")
    public JsonResult batchDelete(@RequestBody Long[] targetIds) {
        postRepository.deleteBatchIds(Arrays.asList(targetIds));
        return JsonResult.success();
    }

    private long generateDistributedId() {
        return System.currentTimeMillis() + (long)(Math.random() * 1000);
    }
}

品質保証とテスト戦略

開発フェーズ終了後は、ローカル環境でのデプロイを基盤として、白箱テストと黒箱テストを併用した検証サイクルを実施します。医療情報システムにおいては、データの不整合や未入力フィールドによるシステム停止は許容されないため、ユースケース駆動型のテストケース設計を採用します。パーレートの法則に基づき、全体の不具合の約8割が2割のモジュールに集中する特性を活かし、診療録登録フロー、薬剤在庫同期ロジック、権限チェックフィルタ等重点領域に対して網羅的な単体テストと結合テストを実行します。また、負荷テストにより並列接続時のメモリ消費パターンを分析し、ガベージコレクションの調整閾値を最適化します。

技術的優位性とアーキテクチャの考察

本プラットフォームは、従来のデスクトップ型管理软件と比較し、以下の技術的優位性を備えています。まず、Javaによる厳密な型付けと仮想マシン層の抽象化により、コードの可読性と再利用性が向上し、将来的な機能拡張における技術的負債の蓄積を防ぎます。第二に、Spring Bootのオートコンフィグレーションとモジュール化設計により、ビュー層とビジネスロジック層の結合度を最小限に抑え、並行開発体制をスムーズに運用可能にしています。第三に、MySQL 5.7のトランザクション分離レベルと索引最適化を活用することで、大量の患者レコードに対する検索クエリ応答時間を安定化し、データ整合性とセキュリティ要件を同時に満たしています。これらの要素を統合した設計により、地域医療現場におけるデジタルトランスフォーメーションを現実的なコストと開発期間で実現する基盤を提供します。

タグ: SpringBoot vue.js MySQL Java 医療情報システム

7月3日 22:53 投稿