メインコンテンツまでスキップ

備品管理システム仕様書

1. システム概要

1.1 目的

本システムは、会津大学学園祭(通称: 蒼翔祭、略称: 学祭)実行委員会(学祭委員) における備品管理の中核を担うシステムである。

学祭で使用する備品の貸出・返却・在庫管理を効率化し、リアルタイムで備品の状態を把握できる環境を提供する。

1.2 システム構成の基本方針

Panasonic FZ-N1 (Android 6.0.1) を主たる操作端末(メインインターフェース)とし、現場での高速かつ確実な備品操作を実現する。

Web管理画面(Sho-Roomモジュール) は、マスタ管理や高度な検索、ラベル印刷、備品データの手動バックアップ等の管理的役割を担い、学祭委員全員が使用できるツール として、FZ-N1アプリを補完する位置づけとなる。

1.3 対象ユーザー

  • 学祭委員全員: Webインターフェース(Sho-Room)を使用
  • 現場スタッフ: FZ-N1端末を使用した備品操作
  • 管理者: システム全体の管理、データ同期、年度更新等

2. アーキテクチャ構成

2.1 技術スタック

バックエンド

  • Platform: Cloudflare Workers (Hono)
  • Database: Cloudflare D1 (2 Database 構成)
    • Equipment DB (soshosai-equipment-db): 備品、場所、履歴、ログを管理
    • System DB (soshosai-system-db): 実行委員(スタッフ)情報を管理。認証時に参照
  • KV Store: Cloudflare KV (soshosai-equipment-otp-kv)
    • OTP情報の一時保存(有効期限: 30分)
  • ORM: Drizzle ORM(または同等のTypeScript対応ORM)による型安全なデータベース操作
  • バリデーション: Zod / drizzle-orm/zod による型の正確なバリデーション

フロントエンド

  • Handy (Main): Native Android (Java) - Panasonic FZ-N1専用アプリ
    • Android 6.0.1 (API Level 23)
    • minSdk = 23
  • Web (Sub): React (TypeScript) / Sho-Roomモジュール(レスポンシブデザイン)
    • デプロイ: Cloudflare Pages
    • BFF (Backend for Frontend): Cloudflare Pages Functions
    • テスト: Vitest

2.2 ドメイン構成

本システムは以下のドメイン構成で運用される:

  • login-api: loginapi.soshosai.com - 認証フロントエンド
  • auth-workers: 認証バックエンドサービス群(Service Binding経由)
    • discord-auth-worker: Discord OAuth2認証
    • line-auth-worker: LINE OAuth2認証
    • unified-auth-worker: JWT検証・BAN管理
  • Sho-Room: room.soshosai.com - 学祭委員向け管理画面(本システム)
  • 来場者アプリ: app.soshosai.com - 来場者向けアプリ(別システム)
  • 共通API: api.soshosai.com - バックエンドAPI(共通)

2.3 認証アーキテクチャ

認証は login-apiauth-workers の2層構成で実装する。

認証システムの詳細は認証システム仕様書を参照

概要:

コンポーネント役割
login-api認証フロントエンド。OAuthリダイレクト、Cookie設定
discord-auth-workerDiscord OAuth2認証・ロール検証
line-auth-workerLINE OAuth2認証
unified-auth-workerJWT検証・BAN管理・レート制限

BFFパターン:

Sho-RoomではCloudflare Pages Functionsを使用したBFFパターンを採用。 CookieからJWTを取得し、Authorization: Bearer xxx ヘッダーに変換してAPIに転送する。

Browser → Pages (React) → Pages Functions (BFF) → API → unified-auth-worker
Cookie Header変換

2.4 ID戦略

  • システム内部ID: すべてのユニークID(プライマリキー)は UUID v7 を使用
  • 操作ログの記録者ID: Discord User ID を使用(ItemHistory.actor_id

2.5 データフロー

  • Items(備品): D1がマスター(Single Source of Truth)。手動操作(Export/Import)でのみGoogle Spreadsheetと連携
  • Staffs(名簿): 外部システムによりsoshosai-system-dbへデータが供給される前提。本システムからは 読み取り(認証照合・IDm更新)のみ を行う
  • Locations / Logs: D1内のみで管理
  • KV (Key-Value Store):
    • OTP情報の一時保存(キー: otp:{student_id}, 有効期限: 30分)

2.6 システム構成図


3. データベース設計

本システムは2つのD1データベースを使用する。

詳細は各DB仕様書を参照

3.1 Equipment DB (soshosai-equipment-db)

備品管理専用のデータベース。

テーブル用途
Locations場所マスター
Organizations使用団体マスター
ItemTypes備品種別マスター
Items備品マスター
ItemHistory操作ログ
SyncLog同期ログ
AuthorityChangeLog権限変更ログ

詳細: 備品DB設計

3.2 System DB (soshosai-system-db)

システム共通のデータベース。本システムからは読み取りとNFC IDm更新のみを行う。

テーブル用途
Staffsスタッフマスター(discord_id, student_id, nfc_idm)

詳細: システムDB設計


4. 非機能要件

4.1 パフォーマンス要件

  • 備品一覧画面: 2000件以上のデータでもストレスなく高速表示すること
  • その他画面: 一般的なWebアプリケーションとして妥当な表示速度を維持すること
  • 想定同時接続数: 最大30人

4.2 セキュリティ要件

  • 認証: login-api / soshosai-authorization-serviceが発行するJWTで厳密に検証

  • 認可: soshosai-authorization-serviceで権限を厳密に検証

  • 権限モデル: JWTのpermissionsフィールドに格納されたビットフラグで権限を管理する。Discordロールから自動計算され、ORで合成される。

    操作必要な権限ビット
    備品一覧・詳細閲覧EQUIPMENT_VIEW(64)
    備品登録・編集EQUIPMENT_EDIT(128)
    備品削除EQUIPMENT_DELETE(256)
    備品連携(import/export)EQUIPMENT_INTEGRATION(512)
    備品リセットEQUIPMENT_RESET(1024)
    団体管理EQUIPMENT_MANAGE_ORGANIZATION(2048)
    場所管理EQUIPMENT_MANAGE_LOCATION(4096)
    備品種別管理EQUIPMENT_MANAGE_TYPE(8192)
    備品一括操作EQUIPMENT_BULK_OPERATION(16384)
    システム・特殊操作SYSTEM_MANAGE(32)
    全操作ALL_PERMISSIONS(全ビットOR)

4.3 可用性

  • Cloudflareを使用し、基本的に常時稼働

4.4 データ管理

  • バックアップ/リストア: Cloudflareの機能を利用
  • データ保持期間: 制限なし
  • データ移行: 対応不要
  • 学祭準備期間の特別対応:
    • 学祭前日〜学祭翌日の期間、大学環境のcronサーバーから定期的に全データを取得
    • 専用API(GET /system/export/all)を使用して全備品データをエクスポート
    • 認証方式: JWT認証ではなく、専用の認証トークン(API Key方式)を使用
    • cronサーバーから5分ごとにデータを取得し、大学環境のローカルDBに保存
    • 詳細な実装は別途定義

5. API仕様

5.1 共通仕様

5.1.1 認証

  • 全APIエンドポイントはJWTによる認証が必須
  • Authorization: Bearer <JWT>ヘッダーでトークンを送信
  • JWTペイロードには操作者の Discord User ID を含める(操作ログ記録用)

5.1.2 レスポンス形式

成功時:

{
"status": 200,
"data": { ... }
}

エラー時:

{
"status": 400,
"message": "エラーメッセージ(error.messageの内容)"
}

5.1.3 HTTPステータスコード

enum HttpStatus {
// 成功系
OK = 200, // 成功
CREATED = 201, // 作成成功
ACCEPTED = 202, // 受理
NO_CONTENT = 204, // コンテンツなし(削除成功など)

// リダイレクト系
MOVED_PERMANENTLY = 301, // 恒久的に移動
FOUND = 302, // 発見
NOT_MODIFIED = 304, // 未更新

// クライアントエラー系
BAD_REQUEST = 400, // リクエスト不正
UNAUTHORIZED = 401, // 認証失敗
FORBIDDEN = 403, // 権限不足
NOT_FOUND = 404, // リソースが見つからない
METHOD_NOT_ALLOWED = 405, // 許可されていないメソッド
NOT_ACCEPTABLE = 406, // 受理できない
CONFLICT = 409, // 競合
GONE = 410, // 消滅
PAYLOAD_TOO_LARGE = 413, // ペイロードが大きすぎる
UNSUPPORTED_MEDIA_TYPE = 415, // サポートされていないメディアタイプ
UNPROCESSABLE_ENTITY = 422, // 処理できないエンティティ(バリデーションエラー)
TOO_MANY_REQUESTS = 429, // リクエスト過多

// サーバーエラー系
INTERNAL_SERVER_ERROR = 500, // サーバーエラー
NOT_IMPLEMENTED = 501, // 未実装
BAD_GATEWAY = 502, // 不正なゲートウェイ
SERVICE_UNAVAILABLE = 503, // サービス利用不可
GATEWAY_TIMEOUT = 504, // ゲートウェイタイムアウト
}

主な使用例:

  • 200 OK: 取得・更新成功
  • 201 CREATED: 作成成功
  • 204 NO_CONTENT: 削除成功
  • 400 BAD_REQUEST: リクエスト形式エラー
  • 401 UNAUTHORIZED: 認証失敗、JWTなし/無効
  • 403 FORBIDDEN: 権限不足
  • 404 NOT_FOUND: リソースが見つからない
  • 409 CONFLICT: データ競合(削除制約違反など)
  • 422 UNPROCESSABLE_ENTITY: バリデーションエラー
  • 500 INTERNAL_SERVER_ERROR: サーバー内部エラー

5.1.4 ページネーション

  • Web UI: start(開始位置)、end(終了位置)パラメータで範囲指定
  • FZ-N1: 50件ごと、startendパラメータで範囲指定

5.2 認証API(Auth)

POST /auth/nfc

  • 概要: FZ-N1用。NFCタッチによるログイン
  • 対象: FZ-N1アプリ
  • リクエストボディ:
{
"student_id": "s1234567",
"idm": "0123456789ABCDEF"
}
  • 処理ロジック:
    1. System DBのStaffsテーブルをstudent_idで検索
    2. レコードなし → 403 Forbidden (Error: NOT_IN_MEMBER_LIST)
    3. Staffs.nfc_idmNULL401 Unauthorized (Error: NEED_REGISTRATION)
    4. Staffs.nfc_idmと送信されたidmが不一致 → 403 Forbidden (Error: INVALID_IDM)
    5. 一致 → 200 OK + JWT発行(Discord IDを含む)
  • レスポンス例:
{
"status": 200,
"data": {
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"discord_id": "123456789012345678",
"student_id": "s1234567",
"permissions": 5
}
}
}

POST /auth/register/nfc

  • 概要: FZ-N1用。OTP検証によるIDm紐付け登録
  • 対象: FZ-N1アプリ
  • リクエストボディ:
{
"student_id": "s1234567",
"idm": "0123456789ABCDEF",
"otp": "123456"
}
  • 処理ロジック:
    1. KVからstudent_idをキーとしてOTP情報を取得
    2. OTPが存在しない、または有効期限切れ → 401 Unauthorized (Error: OTP_EXPIRED)
    3. OTPが不一致 → 403 Forbidden (Error: INVALID_OTP)
    4. OTP一致 → System DBのStaffsテーブルをstudent_idで検索
    5. レコードなし → 403 Forbidden
    6. レコードあり → nfc_idmカラムを更新
    7. KVからOTP情報を削除
    8. 200 OK + JWT発行(Discord ID含む)
  • レスポンス: POST /auth/nfcと同様

KVデータ形式 (キー: otp:{student_id}):

{
"otp": "123456",
"discord_id": "123456789012345678",
"expires_at": 1706745600
}

POST /auth/request-otp

  • 概要: FZ-N1用。初回登録時のOTP発行リクエスト
  • 対象: FZ-N1アプリ
  • リクエストボディ:
{
"student_id": "s1234567"
}
  • 処理ロジック:
    1. System DBのStaffsテーブルをstudent_idで検索
    2. レコードなし → 403 Forbidden (Error: NOT_IN_MEMBER_LIST)
    3. nfc_idmが既に登録済み → 409 Conflict (Error: ALREADY_REGISTERED)
    4. レコードあり&未登録 → 6桁のOTPを生成(ランダムな数字)
    5. Discord APIを使用して特定チャンネル(channelId: 1452516548391731272)にプライベートスレッドを作成
    6. スレッド内で該当ユーザーをメンション+OTP+有効期限を表示
    7. OTP情報をKVに保存(キー: otp:{student_id}, 有効期限: 30分)
    8. 200 OKを返却
  • レスポンス例:
{
"status": 200,
"data": {
"message": "OTPを発行しました。Discordを確認してください。",
"expires_in": 1800
}
}

Discord通知内容例:

<@123456789012345678>
初回登録用のOTPを発行しました。

OTP: 123456
有効期限: 2026-01-31 15:30:00 (30分間)

FZ-N1アプリでこのOTPを入力してください。

POST /auth/discord/callback

  • 概要: Sho-Room (Web)用。Discord OAuthコールバック処理
  • 対象: Webアプリ
  • リクエストボディ: OAuth code等
  • レスポンス: JWT発行

5.3 スタッフ情報API(Staffs)

GET /staffs/me

  • 概要: 現在ログイン中のスタッフ情報を取得
  • 権限: 全ユーザー(要JWT認証)
  • レスポンス例:
{
"status": 200,
"data": {
"discord_id": "123456789012345678",
"student_id": "s1234567",
"permissions": 5
}
}

PUT /staffs/me

  • 概要: 自分のスタッフ情報を更新
  • 権限: 全ユーザー(要JWT認証)
  • 備考: 現時点では更新可能な項目なし(将来的な拡張用)

5.4 場所管理API(Locations)

GET /locations

  • 概要: 全場所リストの取得
  • 権限: 全ユーザー
  • レスポンス例:
{
"status": 200,
"data": {
"locations": [
{
"id": "018d3f7a-5c4e-7b2a-9d1f-3e8a6c4b2f1a",
"name": "M1",
"created_at": "2024-01-15T10:00:00",
"updated_at": "2024-01-15T10:00:00"
},
{
"id": "018d3f7a-6d5f-8c3b-ae2g-4f9b7d5c3g2b",
"name": "講義棟",
"created_at": "2024-01-15T10:01:00",
"updated_at": "2024-01-15T10:01:00"
}
]
}
}

POST /locations

  • 概要: 新規場所の登録
  • 権限: EQUIPMENT_MANAGE_LOCATION
  • リクエストボディ:
{
"name": "体育館"
}
  • 処理: idにはUUID v7を自動生成
  • バリデーション:
    • name: 必須、1-50文字、空白のみ不可
  • レスポンス例:
{
"status": 201,
"data": {
"id": "018d3f7a-7e6g-9d4c-bf3h-5ga8e6d4h3c",
"name": "体育館",
"created_at": "2024-01-26T14:30:00",
"updated_at": "2024-01-26T14:30:00"
}
}

PUT /locations/:id

  • 概要: 場所情報の更新
  • 権限: EQUIPMENT_MANAGE_LOCATION
  • パスパラメータ: id (UUID)
  • リクエストボディ:
{
"name": "新体育館"
}
  • バリデーション: POST /locationsと同様
  • レスポンス: 更新後の場所情報

DELETE /locations/:id

  • 概要: 場所の削除
  • 権限: EQUIPMENT_MANAGE_LOCATION
  • パスパラメータ: id (UUID)
  • 制約: Itemsテーブルで参照されている場合(original_location_idcurrent_location_id)、削除を拒否し409 Conflictを返す
  • レスポンス:
{
"status": 204
}

5.5 団体管理API(Organizations)

GET /organizations

  • 概要: 全団体リストの取得
  • 権限: 全ユーザー
  • レスポンス例:
{
"status": 200,
"data": {
"organizations": [
{
"id": "018d3f7a-8f7h-ae5d-cg4i-6hb9f7e5i4d",
"name": "展示ブース A",
"created_at": "2024-01-15T10:00:00",
"updated_at": "2024-01-15T10:00:00"
}
]
}
}

POST /organizations

  • 概要: 新規団体の登録
  • 権限: EQUIPMENT_MANAGE_ORGANIZATION
  • リクエストボディ:
{
"name": "展示ブース B"
}
  • バリデーション:
    • name: 必須、1-100文字、空白のみ不可

PUT /organizations/:id

  • 概要: 団体情報の更新
  • 権限: EQUIPMENT_MANAGE_ORGANIZATION

DELETE /organizations/:id

  • 概要: 団体の削除
  • 権限: EQUIPMENT_MANAGE_ORGANIZATION
  • 制約: Itemsテーブルで参照されている場合、削除を拒否し409 Conflictを返す

5.6 備品種別管理API(ItemTypes)

GET /item-types

  • 概要: 全備品種別リストの取得
  • 権限: 全ユーザー
  • レスポンス例:
{
"status": 200,
"data": {
"item_types": [
{
"id": "018d3f7a-9g8i-bf6e-dh5j-7ic0g8f6j5e",
"name": "長机",
"created_at": "2024-01-15T10:00:00",
"updated_at": "2024-01-15T10:00:00"
},
{
"id": "018d3f7a-ah9j-cg7f-ei6k-8jd1h9g7k6f",
"name": "椅子",
"created_at": "2024-01-15T10:01:00",
"updated_at": "2024-01-15T10:01:00"
}
]
}
}

POST /item-types

  • 概要: 新規備品種別の登録
  • 権限: EQUIPMENT_MANAGE_TYPE
  • リクエストボディ:
{
"name": "プロジェクター"
}
  • バリデーション:
    • name: 必須、1-50文字、空白のみ不可

PUT /item-types/:id

  • 概要: 備品種別情報の更新
  • 権限: EQUIPMENT_MANAGE_TYPE

DELETE /item-types/:id

  • 概要: 備品種別の削除
  • 権限: EQUIPMENT_MANAGE_TYPE
  • 制約: Itemsテーブルで参照されている場合、削除を拒否し409 Conflictを返す

5.7 備品操作・検索API(Items)

GET /items

  • 概要: 多機能検索とページネーション
  • 権限: 全ユーザー
  • クエリパラメータ:
    • start (integer, optional): 開始位置(デフォルト: 0)
    • end (integer, optional): 終了位置(デフォルト: 50)
    • q (string, optional): フリーワード検索(display_iditem_type_nameorganization_nameへの部分一致)
    • status[] (string[], optional): ステータスフィルタ(複数指定可: STOCK, LENT, RETURNED)
    • condition[] (string[], optional): コンディションフィルタ(複数指定可)
    • current_location_id[] (string[], optional): 現在地フィルタ(複数指定可)
    • item_type_id[] (string[], optional): 種別フィルタ(複数指定可)
    • organization_id[] (string[], optional): 団体フィルタ(複数指定可)
    • sort_by (string, optional): ソート項目(display_id/created_at/updated_at、デフォルト: display_id)
    • sort_order (string, optional): ソート順(asc/desc、デフォルト: asc)
  • レスポンス例:
{
"status": 200,
"data": {
"items": [
{
"id": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"display_id": "DESK-001",
"item_type_id": "018d3f7a-9g8i-bf6e-dh5j-7ic0g8f6j5e",
"item_type_name": "長机",
"status": "LENT",
"condition": "NORMAL",
"original_location_id": "018d3f7a-5c4e-7b2a-9d1f-3e8a6c4b2f1a",
"original_location_name": "M1",
"current_location_id": "018d3f7a-6d5f-8c3b-ae2g-4f9b7d5c3g2b",
"current_location_name": "講義棟",
"organization_id": "018d3f7a-8f7h-ae5d-cg4i-6hb9f7e5i4d",
"organization_name": "展示ブース A",
"note": "脚部に傷あり",
"is_active": 1,
"created_at": "2024-01-15T10:00:00",
"updated_at": "2024-01-26T09:00:00"
}
],
"meta": {
"total_count": 1523,
"start": 0,
"end": 50
}
}
}

GET /items/:id

  • 概要: 備品詳細情報の取得
  • 権限: 全ユーザー
  • パスパラメータ: id (UUID)
  • レスポンス例:
{
"status": 200,
"data": {
"id": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"display_id": "DESK-001",
"item_type_id": "018d3f7a-9g8i-bf6e-dh5j-7ic0g8f6j5e",
"item_type_name": "長机",
"status": "LENT",
"condition": "NORMAL",
"original_location_id": "018d3f7a-5c4e-7b2a-9d1f-3e8a6c4b2f1a",
"original_location_name": "M1",
"current_location_id": "018d3f7a-6d5f-8c3b-ae2g-4f9b7d5c3g2b",
"current_location_name": "講義棟",
"organization_id": "018d3f7a-8f7h-ae5d-cg4i-6hb9f7e5i4d",
"organization_name": "展示ブース A",
"note": "脚部に傷あり",
"is_active": 1,
"created_at": "2024-01-15T10:00:00",
"updated_at": "2024-01-26T09:00:00"
}
}

POST /items

  • 概要: 新規備品の登録
  • 権限: EQUIPMENT_EDIT
  • リクエストボディ:
{
"display_id": "DESK-002",
"item_type_id": "018d3f7a-9g8i-bf6e-dh5j-7ic0g8f6j5e",
"original_location_id": "018d3f7a-5c4e-7b2a-9d1f-3e8a6c4b2f1a",
"note": "新品"
}
  • バリデーション:
    • display_id: 必須、1-50文字、重複不可、空白のみ不可
    • item_type_id: 必須、存在するItemTypeのIDであること
    • original_location_id: 必須、存在するLocationのIDであること
    • note: 任意、最大500文字
  • 処理: idにはUUID v7を自動生成、statusSTOCKconditionNORMALcurrent_location_idoriginal_location_idと同じ値で初期化
  • レスポンス: 作成された備品情報(status: 201)

PUT /items/:id

  • 概要: 備品情報の更新
  • 権限: EQUIPMENT_EDIT
  • パスパラメータ: id (UUID)
  • リクエストボディ:
{
"display_id": "DESK-002-NEW",
"item_type_id": "018d3f7a-9g8i-bf6e-dh5j-7ic0g8f6j5e",
"status": "STOCK",
"condition": "NEEDS_REPAIR",
"original_location_id": "018d3f7a-5c4e-7b2a-9d1f-3e8a6c4b2f1a",
"current_location_id": "018d3f7a-6d5f-8c3b-ae2g-4f9b7d5c3g2b",
"organization_id": null,
"note": "脚部修理必要"
}
  • バリデーション: POST /itemsと同様(display_id変更時は重複チェック)
  • レスポンス: 更新後の備品情報

DELETE /items/:id

  • 概要: 備品の削除(論理削除)
  • 権限: EQUIPMENT_DELETE
  • パスパラメータ: id (UUID)
  • 処理: is_activeを0に設定(論理削除)
  • レスポンス:
{
"status": 204
}

POST /items/organization-action/preview

  • 概要: 出展許可証QRスキャン時の事前確認(一括操作プレビュー)
  • 権限: 全スタッフ
  • リクエストボディ:
{
"organization_id": "018d3f7a-8f7h-ae5d-cg4i-6hb9f7e5i4d"
}
  • レスポンス例:
{
"status": 200,
"data": {
"organization": {
"id": "018d3f7a-8f7h-ae5d-cg4i-6hb9f7e5i4d",
"name": "展示ブース A"
},
"summary": [
{
"item_type_name": "長机",
"count": 4
},
{
"item_type_name": "椅子",
"count": 10
}
],
"items": [
{
"id": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"display_id": "DESK-001",
"item_type_name": "長机",
"status": "LENT",
"condition": "NORMAL"
},
{
"id": "018d3f7a-cj1l-ei9h-gk8m-alf3j1i9m8h",
"display_id": "CHAIR-001",
"item_type_name": "椅子",
"status": "LENT",
"condition": "BROKEN"
}
]
}
}

POST /items/organization-action

  • 概要: 団体一括操作の実行(一括貸出/一括返却)
  • 権限: 全スタッフ
  • リクエストボディ:
{
"organization_id": "018d3f7a-8f7h-ae5d-cg4i-6hb9f7e5i4d",
"action": "LEND"
}
  • 処理ロジック:
    1. 指定されたorganization_idに割り当てられている全備品のステータスを一括更新
    2. actionLENDの場合: statusLENTに変更
    3. actionRETURNの場合: statusRETURNEDに変更
    4. ItemHistoryにログを追加(actor_idにはJWT内のDiscord IDを使用)
  • レスポンス例:
{
"status": 200,
"data": {
"updated_count": 14,
"organization_name": "展示ブース A"
}
}

POST /items/sync

  • 概要: FZ-N1からのバッチ更新(個別スキャン結果の送信)
  • 権限: 全スタッフ
  • リクエストボディ:
{
"updates": [
{
"id": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"status": "RETURNED",
"current_location_id": "018d3f7a-5c4e-7b2a-9d1f-3e8a6c4b2f1a",
"condition": "NORMAL"
},
{
"id": "018d3f7a-cj1l-ei9h-gk8m-alf3j1i9m8h",
"status": "STOCK",
"current_location_id": "018d3f7a-5c4e-7b2a-9d1f-3e8a6c4b2f1a",
"condition": "NEEDS_REPAIR"
}
]
}
  • 処理: 各備品を更新し、ItemHistoryにログを記録
  • レスポンス例:
{
"status": 200,
"data": {
"success_count": 2,
"failed_count": 0
}
}

GET /items/:id/history

  • 概要: 特定備品の操作履歴取得
  • 権限: 全ユーザー
  • パスパラメータ: id (UUID)
  • クエリパラメータ:
    • start (integer, optional): 開始位置
    • end (integer, optional): 終了位置
  • レスポンス例:
{
"status": 200,
"data": {
"history": [
{
"id": 1523,
"item_id": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"actor_id": "123456789012345678",
"condition_type": "NORMAL",
"action_type": "RETURN",
"created_at": "2024-01-26T18:00:00"
},
{
"id": 1450,
"item_id": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"actor_id": "123456789012345678",
"condition_type": "NORMAL",
"action_type": "LEND",
"created_at": "2024-01-26T09:00:00"
}
],
"meta": {
"total_count": 15,
"start": 0,
"end": 50
}
}
}

5.8 ダッシュボードAPI(Dashboard)

GET /dashboard/summary

  • 概要: ダッシュボード用の集計データ取得
  • 権限: 全ユーザー
  • レスポンス例:
{
"status": 200,
"data": {
"metrics": {
"total_items": 1523,
"lent_items": 432,
"stock_items": 1089,
"needs_attention": 2
},
"recent_logs": [
{
"id": 1523,
"item_display_id": "DESK-001",
"item_type_name": "長机",
"actor_id": "123456789012345678",
"action_type": "RETURN",
"created_at": "2024-01-26T18:00:00"
}
]
}
}

GET /dashboard/location-stats

  • 概要: 場所ごとの備品状況を集計
  • 権限: 全ユーザー
  • レスポンス例:
{
"status": 200,
"data": {
"storage_view": [
{
"location_id": "018d3f7a-5c4e-7b2a-9d1f-3e8a6c4b2f1a",
"location_name": "M1",
"original_count": 500,
"current_count": 320
}
],
"venue_view": [
{
"location_id": "018d3f7a-6d5f-8c3b-ae2g-4f9b7d5c3g2b",
"location_name": "講義棟",
"current_count": 180
}
]
}
}

5.9 システム管理API(System)

POST /system/sync/export

  • 概要: D1 Items → Google Spreadsheet(バックアップ)
  • 権限: EQUIPMENT_INTEGRATION
  • レスポンス例:
{
"status": 200,
"data": {
"exported_count": 1523,
"timestamp": 1706745600
}
}
  • 処理: SyncLogテーブルに実行結果を記録

POST /system/sync/import

  • 概要: Google Spreadsheet → D1 Items(一括取込/上書き)
  • 権限: EQUIPMENT_INTEGRATION
  • レスポンス例:
{
"status": 200,
"data": {
"imported_count": 1523,
"timestamp": 1706745600
}
}
  • 処理: SyncLogテーブルに実行結果を記録

POST /system/reset

  • 概要: 年度更新用全リセット
  • 権限: EQUIPMENT_RESET
  • 処理ロジック:
    1. 全ItemsのstatusSTOCKにリセット
    2. current_location_idoriginal_location_idに戻す
    3. organization_idをクリア
    4. ItemHistoryに”RESET”ログを記録
    5. 古いログをアーカイブ(削除)
  • レスポンス例:
{
"status": 200,
"data": {
"reset_count": 1523,
"timestamp": 1706745600
}
}

GET /system/export/all

  • 概要: 学祭準備期間用全データエクスポート(大学環境のcronサーバー用)
  • 権限: 専用API Key認証(JWT認証不使用)
  • 認証方式:
    • ヘッダー: X-API-Key: <API_KEY>
    • 環境変数CRON_API_KEYと照合
  • レスポンス例:
{
"status": 200,
"data": {
"items": [
{
"id": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"display_id": "DESK-001",
"item_type_name": "長机",
"status": "LENT",
"condition": "NORMAL",
"original_location_name": "M1",
"current_location_name": "講義棟",
"organization_name": "展示ブース A",
"note": "脚部に傷あり",
"updated_at": "2026-01-26T09:00:00"
}
],
"total_count": 1523,
"exported_at": "2026-01-31T15:00:00"
}
}
  • 備考:
    • 5分ごとにcronから呼び出される想定
    • 全備品データを一括で返却(ページネーションなし)
    • 大学環境のローカルDBに保存される

5.10 特殊操作API(Special Operations)

SYSTEM_MANAGE権限を持つユーザーのみが実行できる特殊な操作機能。

POST /special/idm-reregister

  • 概要: 学生証IDmの再登録(学生証再発行対応)
  • 権限: SYSTEM_MANAGE
  • リクエストボディ:
{
"student_id": "s1234567",
"new_idm": "0123456789ABCDEF"
}
  • 処理ロジック:
    1. System DBのStaffsテーブルをstudent_idで検索
    2. レコードなし → 403 Forbidden (Error: NOT_IN_MEMBER_LIST)
    3. レコードあり → nfc_idmnew_idmで更新
    4. 操作ログを記録
  • レスポンス例:
{
"status": 200,
"data": {
"student_id": "s1234567",
"name": "会津太郎",
"old_idm": "FEDCBA9876543210",
"new_idm": "0123456789ABCDEF",
"updated_at": "2026-01-31T15:00:00"
}
}

POST /special/force-status-change

  • 概要: 備品状態の強制変更(緊急時の異常状態修正)
  • 権限: SYSTEM_MANAGE
  • リクエストボディ:
{
"item_id": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"status": "STOCK",
"condition": "NORMAL",
"organization_id": null,
"reason": "誤操作により状態が異常になったため強制修正"
}
  • 処理ロジック:
    1. 指定された備品の状態を強制的に変更
    2. ItemHistoryに特殊操作ログを記録(理由を含む)
  • レスポンス例:
{
"status": 200,
"data": {
"item_id": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"display_id": "DESK-001",
"updated_fields": ["status", "condition", "organization_id"]
}
}

GET /special/staff-list

  • 概要: 学祭委員一覧の取得
  • 権限: SYSTEM_MANAGE
  • クエリパラメータ:
    • start (integer, optional): 開始位置(デフォルト: 0)
    • end (integer, optional): 終了位置(デフォルト: 50)
    • search (string, optional): 学籍番号・氏名で検索
  • レスポンス例:
{
"status": 200,
"data": {
"staffs": [
{
"discord_id": "123456789012345678",
"student_id": "s1234567",
"permissions": 5
}
],
"total": 150,
"start": 0,
"end": 50
}
}

POST /special/staff-by-nfc

  • 概要: NFCスキャンによる学祭委員情報取得
  • 権限: SYSTEM_MANAGE
  • リクエストボディ:
{
"student_id": "s1234567",
"idm": "0123456789ABCDEF"
}
  • 処理ロジック:
    1. System DBのStaffsテーブルをstudent_idまたはnfc_idmで検索
    2. レコードなし → 403 Forbidden
    3. レコードあり → スタッフ情報を返却
  • レスポンス例:
{
"status": 200,
"data": {
"discord_id": "123456789012345678",
"student_id": "s1234567",
"permissions": 5
}
}

POST /special/change-authority

⚠️ 廃止予定: 権限はDiscordロールから自動計算されるため、このエンドポイントで変更しても次回ログイン時に上書きされる。権限変更はDiscordサーバーのロール管理で行うこと。

  • 概要: 学祭委員の権限変更(昇格/降格)
  • 権限: SYSTEM_MANAGE
  • リクエストボディ:
{
"discord_id": "123456789012345678",
"new_permissions": 128,
"reason": "学祭期間中の責任者として一時的に昇格"
}
  • 処理ロジック:
    1. System DBのStaffsテーブルをdiscord_idで検索
    2. レコードなし → 404 Not Found
    3. 操作ログを記録(変更前・変更後の権限ビット、理由を含む)
  • レスポンス例:
{
"status": 200,
"data": {
"discord_id": "123456789012345678",
"student_id": "s1234567",
"old_permissions": 5,
"new_permissions": 128,
"updated_at": "2026-01-31T15:00:00"
}
}

6. エラー処理とログ

6.1 バックエンド(Hono)

  • エラーハンドリング: try-catchでキャッチしたerror.messageをそのままログに記録
  • ログ送信: Service Bindingを使用した外部エラーログシステムに送信
  • エラーレスポンス: HTTPステータスコードとエラーメッセージを返却
// エラーハンドリング例
try {
// 処理
} catch (error) {
// Service Bindingでログ送信
await env.ERROR_LOG.send({
message: error.message,
stack: error.stack,
timestamp: Date.now(),
});

// クライアントにエラーレスポンス
return c.json(
{
status: 500,
message: error.message,
},
500,
);
}

6.2 フロントエンド(Web App)

  • エラー収集: エラーを内部で収集
  • ログ送信: Sho-Roomのエラーログ送信機能と連携
  • エラー表示: ユーザーにわかりやすいエラーメッセージを表示

6.3 FZ-N1アプリ

  • エラー表示: ダイアログまたはトースト通知でエラー表示
  • ログ記録: ローカルにログを保存し、オンライン時に送信
  • 音声フィードバック: エラー時はBeep音で通知

7. ハンディアプリ仕様(FZ-N1 / Android 6.0.1)

7.1 開発環境

  • Android Version: 6.0.1 (API Level 23)
  • minSdk: 23
  • 言語: Java 8
  • 通信: HttpURLConnection または OkHttp 3.x(TLS 1.2対応必須)
  • ビルドコマンド: ./gradlew clean assembleDebug
  • Toughpad SDK:
    • Panasonic提供のToughpad SDK (.jar形式)
    • 物理ボタン制御用ライブラリ
    • A1-A3ボタン、サイドボタンのイベントハンドリング

7.1.1 物理ボタン仕様

FZ-N1には以下の物理ボタンが搭載されています:

画面下部の3つのボタン:

  • A1ボタン (左): キャンセル・拒否操作
  • A2ボタン (中央): 補助操作(画面により機能が変わる)
  • A3ボタン (右): 確認・承認操作

サイドボタン:

  • 左サイドボタン: 補助操作(現時点では未使用)
  • 右サイドボタン: 補助操作(現時点では未使用)

動的な割り当て原則:

  • 物理ボタンは 必要な画面でのみ機能を割り当て、不要な画面では何も動作しない
  • 各画面で使用するボタンのみリスナーを登録する
  • 使用しないボタンはイベントを無視する

画面ごとの割り当て例:

画面A1ボタンA2ボタンA3ボタン
一括操作確認キャンセル概要/詳細切替実行
ログイン---
OTP入力---
メインメニュー---
個別スキャン確認キャンセル-保存

Toughpad SDK使用例:

// Toughpad SDKのインポート
import jp.co.panasonic.toughpad.android.api.appbtn.AppButtonManager;
import jp.co.panasonic.toughpad.android.api.appbtn.AppButtonManager.AppButtonListener;

// ボタンイベントリスナーの設定
AppButtonManager manager = new AppButtonManager(this);

// 一括操作確認画面の場合
manager.setAppButtonListener(new AppButtonListener() {
@Override
public void onAppButtonEvent(int buttonId, int event) {
if (event == AppButtonManager.APP_BUTTON_EVENT_DOWN) {
switch (buttonId) {
case AppButtonManager.APP_BUTTON_A1:
// A1ボタン(キャンセル)の処理
onCancelButtonPressed();
break;
case AppButtonManager.APP_BUTTON_A2:
// A2ボタン(概要/詳細切替)の処理
toggleDetailView();
break;
case AppButtonManager.APP_BUTTON_A3:
// A3ボタン(確認)の処理
onConfirmButtonPressed();
break;
}
}
}
});

// 画面を離れる時はリスナーを解除
@Override
protected void onDestroy() {
super.onDestroy();
if (manager != null) {
manager.release();
}
}

画面表示イメージ(一括操作確認画面):

┌─────────────────────────┐
│ │
│ (メインコンテンツ) │
│ │
├─────────────────────────┤
│ │
│ [キャンセル] [切替] [実行] │ ← A1, A2, A3ボタンに対応
│ │
└─────────────────────────┘
  • A1ボタンを押すと「キャンセル」
  • A2ボタンを押すと「概要/詳細切替」
  • A3ボタンを押すと「実行」
  • 画面下部には物理ボタンに対応する表示を配置

7.2 認証フロー詳細

アプリ起動時はログイン画面を表示する。

A. NFCログイン(Primary Flow)

ステップ1: NFCリーダー待機

  1. アプリ起動後、NFCリーダーを有効化しFeliCaをポーリング
  2. 画面に「学生証をタッチしてください」と表示

ステップ2: FeliCa検知と読取

  • System Code: 0x809E
  • PMm: 03 32 42 82 82 47 AA FF(会津大学学生証識別)
  • 読取ロジック:
  1. IDm(8byte)を取得
  2. Service Code 0x300B Block 0を読み取る
  3. 取得した16byteデータに対し、先頭4文字をスキップし、続く7文字を抽出(例: 1234567
  4. 先頭にsを付与し、s1234567形式の学籍番号を生成
    • 例外処理: 上記以外のカード → エラー音(Beep) + 画面に「無効なカードです」と表示

ステップ3: API照合

  • POST /auth/nfcを送信

  • Body: { "student_id": "s1234567", "idm": "0123456789ABCDEF" }

  • レスポンス処理:

    • 成功(200):
    1. **成功音を鳴らす**
    2. **確認画面を表示**(学籍番号を表示)
    3. 「OK」ボタンタップで **メインメニューへ遷移**
    • 許可なし(403): 警告音 + エラーダイアログ「権限がありません(名簿未登録)」
    • 未登録(401 - NEED_REGISTRATION): 初回登録フロー(B)へ遷移

確認画面UI:

  • タイトル: 「ログイン確認」
  • 表示項目:
    • 学籍番号: s1234567
  • ボタン: 「OK」

B. OTPによる初回登録(Secondary Flow)

APIから「名簿にはあるがIDm未登録」と判定された場合、Discord経由でOTPを発行し、FZ-N1アプリで入力して本人確認を行う。

ステップ1: OTP発行リクエスト

  1. POST /auth/request-otpを送信
    • Body: { "student_id": "s1234567" }
  2. APIがDiscordの特定チャンネル(channelId: 1452516548391731272)にプライベートスレッドを作成
  3. スレッド内で該当ユーザーをメンション+6桁のOTP+有効期限を表示
  4. OTP情報がKVに保存される(有効期限: 30分)

ステップ2: OTP入力画面表示

  • UI構成:
    • タイトル: 「本人確認」
    • メッセージ: 「Discordに送信されたOTPを入力してください」
    • 独自実装のテンキー(6桁入力用):
      • 0-9の数字ボタン
      • バックスペースボタン(入力欄右端に表示)
      • クリアボタン
    • 入力表示エリア(6桁、入力した数字をそのまま表示)
    • ボタン: 「確認」
    • 注意事項: 「OTPの有効期限は30分です」

ステップ3: OTP検証

  1. ユーザーが6桁のOTPを入力
  2. 「確認」ボタンタップでPOST /auth/register/nfcを送信
    • Body: { "student_id": "s1234567", "idm": "0123456789ABCDEF", "otp": "123456" }
  3. レスポンス処理:
    • 成功(200): 成功音 + 「登録完了」トースト + メインメニューへ遷移
    • OTP期限切れ(401 - OTP_EXPIRED): エラー音 + 「OTPの有効期限が切れています。最初からやり直してください」
    • OTP不一致(403 - INVALID_OTP): エラー音 + 「OTPが正しくありません」
    • 通信エラー: エラー音 + 「通信エラー: 再試行してください」

テンキーUI仕様:

┌─────────────────────────┐
│ワンタイムパスワード入力: 123456 ← │
├─────────────────────────┤
│ [ 1 ] [ 2 ] [ 3 ] │
│ [ 4 ] [ 5 ] [ 6 ] │
│ [ 7 ] [ 8 ] [ 9 ] │
│ [ C ] [ 0 ] [ 確認 ] │
└─────────────────────────┘
  • 入力した数字をそのまま表示(マスクなし)
  • ←: バックスペース表示(入力欄右端に表示、1桁削除)
  • C: クリア(全削除)
  • 確認: OTP検証実行(6桁入力完了後に有効化)

7.3 メニュー・ナビゲーション

ログイン後、以下の機能をタイル状(またはリスト)のメニューで選択可能にする。

  1. 個別スキャン(Single Scan)
  2. 一括貸出(Organization Lend)
  3. 一括返却(Organization Return)
  4. 設定(Settings): ログアウト、未送信データ確認

7.3.1 ログアウト機能

ログアウト方法:

  • メニューから「設定」を選択し、「ログアウト」ボタンをタップ
  • アプリ終了時に自動的にログアウト

ログアウト処理:

  1. ローカルに保存されたJWTを削除
  2. 未送信データがある場合、確認ダイアログを表示
    • 「送信してログアウト」: 未送信データを送信後にログアウト
    • 「データを破棄してログアウト」: 未送信データを削除してログアウト
    • 「キャンセル」: ログアウトを中止
  3. ログイン画面へ遷移

7.4 機能詳細: 団体一括操作(Organization Action)

一括貸出フロー:

  1. QRスキャン: 出展許可証QR(https://.../?organization_id=...形式のURL)を読み取る
  2. プレビューAPIコール: POST /items/organization-action/preview
  3. プレビュー画面(UI詳細):
    • ヘッダー: 団体名を表示
    • 表示切替: 上部に [概要] [詳細] のセグメントボタン(またはタブ)
    • [概要]タブ:
      • デフォルト表示
      • 長机: 4, 椅子: 10のように種別と個数を大きな文字でリスト表示
    • [詳細]タブ:
      • 全対象備品のリスト
      • 各行: [DisplayID] [種別] [状態(日本語)]
      • 警告表示: conditionNORMAL以外(破損・紛失等)の場合、行の背景色を赤/黄色にし、警告アイコンを表示
    • フッター(物理ボタン対応):
┌─────────────────────────┐
│ │
│ [キャンセル] [切替] [実行] │ ← A1, A2, A3の物理ボタンに対応
│ │
└─────────────────────────┘
  • A1ボタン(左): キャンセル → 前の画面に戻る
  • A2ボタン(中央): 概要/詳細切替 → 表示モードを切り替え
  • A3ボタン(右): 実行 → 一括貸出を実行
  1. 実行: A3ボタン押下で API POST /items/organization-action送信 → 完了画面
    • Body: { "organization_id": "...", "action": "LEND" }
  2. 完了音: 成功音を鳴らし、メインメニューへ戻る

一括返却フロー:

  • 一括貸出と同様の流れ
  • actionパラメータをRETURNに設定
  • フッター表示:
┌─────────────────────────┐
│ │
│ [キャンセル] [切替] [返却] │ ← A1, A2, A3の物理ボタンに対応
│ │
└─────────────────────────┘
  • A2ボタンで概要/詳細を切り替え可能

7.5 機能詳細: 個別スキャン

動作フロー:

  1. バーコードをスキャン(備品のUUID v7が格納されたQRコード)
  2. API検索(GET /items/:id
  3. 結果表示

表示内容:

  • Display ID(DESK-001等)を画面中央に大きく表示
  • 現在のステータス(在庫/貸出中/返却済)とコンディション(正常/要修理/破損等)を表示
  • 備品種別、現在地、使用団体を表示

操作:

  • その場でCondition(正常/破損/…)やStatus(在庫/貸出…)を変更するボタン/プルダウンを配置
  • 変更後、POST /items/syncで一括送信

7.6 オフライン対応

基本動作:

  • ネットワーク接続が切れた場合、操作結果をローカルに保存
  • スキャン時に操作日時をタイムスタンプとして保存
  • オンライン復帰時にPOST /items/syncで一括送信
  • 未送信データがある場合、設定画面で確認・再送信可能

競合処理:

  • 同期時に各備品のupdated_atをサーバーから取得
  • ローカルの操作日時とサーバーのupdated_atを比較
  • サーバーのupdated_atの方が新しい場合、その備品の更新をスキップ(競合回避)
  • スキップされた備品は同期結果画面で通知

7.7 音声フィードバック

  • 成功音: 操作成功時(貸出/返却完了、ログイン成功)
  • エラー音: エラー発生時(無効なカード、権限なし、通信エラー)
  • 警告音: 注意が必要な場合(破損備品の検出など)

8. Webアプリ仕様(Sho-Roomモジュール)

8.1 開発環境

基本構成

  • フレームワーク: React 18+ (TypeScript)
  • スタイリング: Tailwind CSS
  • ビルドツール: Vite
  • デプロイ: Cloudflare Pages
  • BFF: Cloudflare Pages Functions
  • 対象デバイス: PCおよびモバイル端末(レスポンシブデザイン)

プロジェクト構造

sho-room/
├── functions/ # Pages Functions (BFF)
│ └── api/
│ └── [[path]].ts # APIプロキシ
├── src/
│ ├── components/ # Reactコンポーネント
│ ├── hooks/ # カスタムフック
│ ├── utils/ # ユーティリティ関数
│ ├── services/ # API通信ロジック
│ ├── types/ # TypeScript型定義
│ ├── App.tsx
│ └── main.tsx
├── tests/ # テストファイル
│ ├── unit/ # 単体テスト
│ └── integration/ # 統合テスト
├── public/
├── package.json
├── vite.config.ts
├── vitest.config.ts
└── tsconfig.json

BFF実装(Pages Functions)

functions/api/[[path]].ts:

// Cloudflare Pages Functions - BFFプロキシ

interface Env {
API_URL: string; // https://api.soshosai.com
}

export async function onRequest(context: {
request: Request;
env: Env;
}): Promise<Response> {
const { request, env } = context;

// Cookieからトークンを取得
const cookieHeader = request.headers.get('Cookie') || '';
const token = cookieHeader
.split('; ')
.find((row) => row.startsWith('soshosai_staff_jwt='))
?.split('=')[1];

if (!token) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}

// api.soshosai.com に転送(CookieをAuthorizationヘッダーに変換)
const url = new URL(request.url);
const apiPath = url.pathname.replace('/api', '');
const apiUrl = `${env.API_URL}${apiPath}${url.search}`;

const response = await fetch(apiUrl, {
method: request.method,
headers: {
Authorization: `Bearer${token}`,
'Content-Type': 'application/json',
},
body:
request.method !== 'GET' && request.method !== 'HEAD'
? await request.text()
: undefined,
});

return response;
}

認証フロー

  1. ログイン: loginapi.soshosai.com でOAuth/Discord認証
  2. リダイレクト: login-apiが soshosai_staff_jwt をCookieに設定して room.soshosai.com にリダイレクト
  3. Cookieの設定:
Set-Cookie: soshosai_staff_jwt=eyJhbGc...;
Domain=room.soshosai.com;
Path=/;
HttpOnly;
Secure;
SameSite=Lax;
Max-Age=604800
  1. API通信: React側は相対パスで呼び出し、Pages FunctionsがプロキシとしてCookieをHeaderに変換

React側の実装例:

// src/services/api.ts
export async function fetchItems() {
// 相対パスで呼ぶだけ(Cookieは自動送信される)
const response = await fetch('/api/items');

if (!response.ok) {
if (response.status === 401) {
// 未認証の場合はlogin-apiにリダイレクト
window.location.href =
'https://loginapi.soshosai.com/login?redirect=room';
return;
}
throw new Error('API request failed');
}

return response.json();
}

開発サーバー

# Pagesの開発サーバー(Functions含む)
npm run dev

# または
wrangler pages dev -- npm run dev

デプロイ

# Cloudflare Pagesにデプロイ
npm run build
wrangler pages deploy dist

8.2 テスト仕様

テストフレームワーク

  • テストランナー: Vitest
  • テストライブラリ: @testing-library/react
  • モック: vi (Vitestビルトイン)

テスト戦略

単体テスト (Unit Tests):

  • 対象: ユーティリティ関数、カスタムフック、純粋な関数
  • 配置: tests/unit/
  • 命名規則: .test.ts または .test.tsx
  • カバレッジ目標: 80%以上

統合テスト (Integration Tests):

  • 対象: コンポーネント + API通信、複数コンポーネントの連携
  • 配置: tests/integration/
  • モック: APIレスポンスをモック

ファイル分割規則

厳密なファイル分割:

// ❌ 悪い例: 1ファイルに複数の責務
// utils.ts
export function formatDate(date: Date) { ... }
export function validateEmail(email: string) { ... }
export function calculateTotal(items: Item[]) { ... }

// ✅ 良い例: 1ファイル1責務
// date.ts
export function formatDate(date: Date) { ... }

// validation.ts
export function validateEmail(email: string) { ... }

// calculation.ts
export function calculateTotal(items: Item[]) { ... }

テストファイルの対応:

src/utils/date.ts → tests/unit/utils/date.test.ts
src/services/api.ts → tests/unit/services/api.test.ts
src/components/Item.tsx → tests/integration/Item.test.tsx

vitest.config.ts

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './tests/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'**/*.test.{ts,tsx}',
'vite.config.ts',
'vitest.config.ts',
],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});

テスト例

単体テスト例(ユーティリティ関数):

// tests/unit/utils/date.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate } from '@/utils/date';

describe('formatDate', () => {
it('should format date to YYYY-MM-DD', () => {
const date = new Date('2026-01-31T15:00:00');
expect(formatDate(date)).toBe('2026-01-31');
});

it('should handle invalid date', () => {
expect(() => formatDate(new Date('invalid'))).toThrow();
});
});

統合テスト例(コンポーネント + API):

// tests/integration/ItemList.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { ItemList } from '@/components/ItemList';
import * as api from '@/services/api';

vi.mock('@/services/api');

describe('ItemList', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should display items from API', async () => {
vi.spyOn(api, 'fetchItems').mockResolvedValue([
{ id: '1', display_id: 'DESK-001', item_type_name: '長机' },
]);

render(<ItemList />);

await waitFor(() => {
expect(screen.getByText('DESK-001')).toBeInTheDocument();
expect(screen.getByText('長机')).toBeInTheDocument();
});
});

it('should show error on API failure', async () => {
vi.spyOn(api, 'fetchItems').mockRejectedValue(new Error('API Error'));

render(<ItemList />);

await waitFor(() => {
expect(
screen.getByText(/エラーが発生しました/),
).toBeInTheDocument();
});
});
});

テスト実行コマンド

# すべてのテストを実行
npm run test

# ウォッチモード
npm run test:watch

# カバレッジ計測
npm run test:coverage

# 特定のファイルのみ
npm run test -- date.test.ts

8.3 ダッシュボード(Dashboard)

管理者がログイン直後に見る画面。

表示要素:

  • ステータスカード: 「総備品数」「貸出中」「在庫」「要対応(破損/紛失)」を表示
  • 在庫分布チャート: 場所ごとの在庫数を棒グラフで可視化
  • 場所別ステータス確認:
    • GET /dashboard/location-statsの結果を表示
    • 元の場所視点(在庫チェック用)と移動先視点(貸出チェック用)のテーブルを表示
  • 最近の操作: ItemHistoryから最新5件を表示

8.4 備品管理(Inventory)

一覧表示:

  • カラム: Display ID, 種別, ステータス, コンディション(日本語), 現在地, 使用団体
  • サーバーサイドページネーションを実装(1ページ50件など。表示件数は変更可能: 10/20/50/100/200)
  • モバイル表示時: カード形式レイアウト等で見やすく調整

検索パネル:

  • フリーワード: display_id, 備品名, 団体名(Debounce対応)
  • フィルタ: 種別、状態、場所、コンディション(複数選択可)

編集モーダル:

  • 備品情報の編集(display_idの変更、備品種別、使用団体、元の場所、現在地、コンディション変更)
  • ラベル印刷ボタン(TM-L90への送信)

カメラQR読取:

  • ブラウザAPIを使用し、備品QRをスキャンして編集モーダルを開く

バリデーション:

  • display_id: 必須、1-50文字、重複不可、空白のみ不可
  • note: 任意、最大500文字

8.5 場所管理(Locations)

一覧表示:

  • 全場所をテーブル形式で表示
  • 備品数(総数、貸出中、在庫)を表示

CRUD操作:

  • 場所の新規作成、編集、論理削除(is_active=0
  • 削除前に備品割当チェック(備品がある場所は削除不可)

8.6 団体管理(Organizations)

一覧表示:

  • 全団体をテーブル形式で表示
  • 割り当てられている備品数を表示

CRUD操作:

  • 団体の新規作成、編集、論理削除
  • 削除前に備品割当チェック

8.7 特殊操作(Special Operations)

アクセス: SYSTEM_MANAGE権限を持つユーザーのみ

機能メニュー:

  1. 学生証IDm再登録

    • 学籍番号入力フォーム
    • NFCカードリーダー対応(PCに接続されている場合)
    • または、手動でIDm入力
    • 実行前に確認ダイアログ表示
    • API: POST /special/idm-reregister
  2. 備品状態の強制変更

    • 備品検索(Display IDまたはQRスキャン)
    • 現在の状態表示
    • 変更後の状態選択(ステータス、コンディション、団体)
    • 理由入力欄(必須、最大500文字)
    • 実行前に確認ダイアログ表示
    • API: POST /special/force-status-change
  3. 権限確認(⚠️ 権限変更はDiscordロール管理で行うこと)

    方法1: 一覧から選択

    • 学祭委員一覧を表示(ページネーション対応)
    • 検索機能: 学籍番号・氏名で検索
    • 各行に現在のpermissionsビット値を表示
    • API: GET /special/staff-list

    方法2: NFCスキャン

    • 「学生証をスキャン」ボタン
    • NFCカードリーダー(PCに接続)で学生証を読み取り
    • 該当する学祭委員情報を表示
    • API: POST /special/staff-by-nfc

注意事項:

  • 全ての特殊操作は操作ログに詳細記録される
  • 実行前に必ず確認ダイアログを表示
  • 理由の入力を必須とする
  • 権限はDiscordロールから自動計算されるPOST /special/change-authority で変更しても次回ログイン時にDiscordロールの値で上書きされる
  • 新規登録ボタン(dept_adminsuper_adminのみ表示)

編集・削除:

  • モーダルで実施
  • 削除時は備品が残っていないかバリデーションを行う(Itemsテーブルで参照されている場合は削除不可)

項目:

  • 場所名(name)の編集に対応

8.8 備品種別管理(ItemTypes)

場所管理と同様の構成。

8.9 システム管理(System)

管理者専用機能super_admindept_adminのみアクセス可能)。

データ同期パネル:

  • 備品エクスポート: 「スプレッドシートへ書き出し(Items Export)」ボタン
    • クリックでPOST /system/sync/exportを実行
  • 備品インポート: 「スプレッドシートから取り込み(Items Import)」ボタン(※赤色の警告ボタン)
    • クリックで確認ダイアログを表示後、POST /system/sync/importを実行

メンテナンス:

  • 年度更新: 「全データをリセット(Reset for New Year)」ボタン(※確認ダイアログ2回表示)
    • super_adminのみ実行可能
    • クリックでPOST /system/resetを実行

9. 外部連携仕様

9.1 Google Spreadsheet

運用ルール:

  • FZ-N1アプリ上またはWeb管理画面からの手動操作(インポート/エクスポート)時のみアクセス
  • D1がマスター(Single Source of Truth)

Sheets構成:

  • Items: 備品データ(D1のミラー)
    • カラム: id, display_id, item_type_name, status, condition, original_location_name, current_location_name, organization_name, note, is_active, created_at, updated_at

9.2 プリンター(Epson TM-L90)

基本情報

機種: Epson TM-L90 Label Printer

接続方式: ネットワーク接続(LAN)

通信方式: e-POS Bridgeを介した印刷

  • 直接HTTPリクエストを送るのではなく、e-POS Bridge(PCまたは専用端末上で動作するElectron製ブリッジサービス)に対して、SDKまたはXMLコマンドを送信する形式
  • Webアプリから、e-POS BridgeのIPアドレスを指定して通信を行う
  • 注意: FZ-N1からの直接印刷は仕様上不可(Webアプリからのみ印刷可能)
  • USBポート: TM-L90にはUSB接続端子が存在しないため、USBモードは使用不可

e-POS Bridge仕様

アプリケーション: Electron製プロキシサーバー

デフォルトポート: 15888 または 8008

CORS: すべてのオリジンを許可

主要エンドポイント:

エンドポイントメソッド説明
/healthGETヘルスチェック・設定確認
/set-printerPOSTプリンター設定の更新
/print/jsonPOSTJSON形式での印刷(推奨)
/epos/service.cgiPOSTePOS-Print XML形式での印刷

プリンター設定

初回設定API: POST /set-printer

{
"ipAddress": "192.168.1.100",
"useSsl": false,
"paperWidthMm": 80,
"connectionType": "network"
}

パラメータ:

  • ipAddress: プリンターのIPアドレス(必須)
  • useSsl: SSL接続の使用(デフォルト: false
  • paperWidthMm: 用紙幅(デフォルト: 80
  • connectionType: 接続タイプ(network 固定、TM-L90にはUSBポートなし)

印刷データ形式

推奨: JSON形式(/print/json

Instruction Mode(順次印刷)を使用:

{
"instructions": [
{
"type": "qr",
"content": "018d3f7a-bi0k-dh8g-fj7l-9ke2i0h8l7g",
"size": 6,
"level": 2,
"align": "center"
},
{ "type": "feed", "lines": 1 },
{
"type": "text",
"content": "長机",
"align": "center",
"fontSize": 48,
"bold": true
},
{ "type": "feed", "lines": 1 },
{
"type": "text",
"content": "M1 → 講義棟",
"align": "center",
"fontSize": 24
},
{ "type": "feed", "lines": 1 },
{
"type": "text",
"content": "展示ブース A",
"align": "center",
"fontSize": 20
},
{ "type": "feed", "lines": 3 },
{ "type": "cut", "cutType": "full" }
]
}

JSON命令タイプ:

  • qr: QRコード生成
    • content: データ(UUIDなど)
    • size: 1-16(モジュールサイズ)
    • level: 0-3(誤り訂正レベル: L/M/Q/H)
    • align: left / center / right
  • text: テキスト印刷(ビットマップレンダリング)
    • content: 印刷するテキスト
    • fontSize: フォントサイズ(ピクセル)
    • bold: 太字(true / false
    • align: 配置
  • feed: 改行
    • lines: 改行数
  • cut: 用紙カット
    • cutType: full / partial

印刷内容仕様

印刷する情報:

  1. QRコード: 備品のUUID(Items.id)を格納
    • サイズ: 6(モジュール)
    • 誤り訂正: レベルM
    • 配置: 中央
  2. 備品種別: 大きな文字で表示
    • フォントサイズ: 48px
    • 太字: ON
    • 配置: 中央
  3. 元の場所と移動先: {元の場所} → {移動先}形式で表示
    • フォントサイズ: 24px
    • 配置: 中央
  4. 使用団体: 団体名を表示
    • フォントサイズ: 20px
    • 配置: 中央

印刷トリガー

Webアプリの実装:

// src/services/printer.ts
async function printLabel(item: Item) {
// e-POS Bridge自動検出
const port = await detectBridge([15888, 8008]);

const printData = {
instructions: [
{
type: 'qr',
content: item.id,
size: 6,
level: 2,
align: 'center',
},
{ type: 'feed', lines: 1 },
{
type: 'text',
content: item.item_type_name,
align: 'center',
fontSize: 48,
bold: true,
},
{ type: 'feed', lines: 1 },
{
type: 'text',
content: `${item.original_location_name}${item.current_location_name}`,
align: 'center',
fontSize: 24,
},
{ type: 'feed', lines: 1 },
{
type: 'text',
content: item.organization_name || '',
align: 'center',
fontSize: 20,
},
{ type: 'feed', lines: 3 },
{ type: 'cut', cutType: 'full' },
],
};

const response = await fetch(`http://localhost:${port}/print/json`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(printData),
});

return response.json();
}

// e-POS Bridge自動検出
async function detectBridge(ports: number[]): Promise<number> {
for (const port of ports) {
try {
const res = await fetch(`http://localhost:${port}/health`, {
signal: AbortSignal.timeout(1000),
});
if (res.ok) return port;
} catch {}
}
throw new Error('e-POS Bridge not found');
}

UIでの使用:

  • Webアプリの備品編集モーダルから「ラベル印刷」ボタンをクリック
  • 印刷成功/失敗をトースト通知で表示

エラーハンドリング

よくあるエラーと対処:

エラー原因対処法
e-POS Bridge not foundアプリが起動していないe-POS Bridgeアプリを起動
プリンターが設定されていません/set-printer未実行プリンター設定APIを呼び出す
接続タイムアウトプリンター電源OFF/ネットワークプリンター電源・ネットワーク確認

開発・デバッグ

ヘルスチェック:

curl http://localhost:15888/health

レスポンス例:

{
"status": "running",
"printer": "configured",
"config": {
"ipAddress": "192.168.1.100",
"useSsl": false,
"paperWidthMm": 80
}
}

10. 運用・保守

10.1 保守体制

  • 管理チームが保守管理を担当

10.2 サポート体制

  • Sho-Room内のメール送信機能でエラーログ等を送信
  • メールでのサポート対応

10.3 監視

  • Cloudflare Analyticsでアクセス状況を監視
  • エラーログの定期確認

11. テスト方針

11.1 Web App

  • 開発環境: wrangler dev --remoteで動作確認
  • テスト環境: concurrencyを使用したHono/React同時実行環境で検証
  • テスト項目:
    • 各機能の動作確認
    • バリデーションテスト
    • 権限別の表示/操作テスト
    • レスポンシブデザイン確認
    • エラーハンドリング確認

11.2 FZ-N1アプリ

  • ビルド: ./gradlew clean assembleDebugで毎回ビルド・検証
  • テスト項目:
    • バーコードスキャン動作確認
    • NFCログイン動作確認
    • OTP認証フロー確認(Discord通知、独自テンキー入力)
    • オフラインモード動作確認
    • API連携テスト
    • エラーハンドリング確認
    • 音声フィードバック確認

11.3 受け入れ基準

  • 全機能が仕様通りに動作すること
  • パフォーマンス要件を満たすこと(備品一覧2000件以上で高速表示)
  • 権限管理が正しく機能すること
  • エラーハンドリングが適切に行われること
  • NFCログインが正常に機能すること
  • オフライン対応が正常に機能すること

12. 権限マトリクス

機能staffdept_adminsystem_adminsuper_admin
備品一覧表示
備品詳細表示
備品登録××
備品編集××
備品削除×××
備品インポート××
備品エクスポート××
貸出処理(個別)
返却処理(個別)
一括貸出
一括返却
備品履歴表示
場所管理××
団体管理××
備品種別管理××
在庫状況表示
システムリセット×××

権限不足時の動作:

  • HTTPステータスコード: 403 FORBIDDEN
  • エラーメッセージ: “この操作を実行する権限がありません”
  • UI: 該当機能のボタン/メニューを非表示

注意事項:

  • system_adminはSho-Room全体の管理者権限を持つが、備品管理に関する特別な権限は持たない
  • 備品関連の管理権限はdept_adminsuper_adminのみが持つ

13. 用語集

用語説明
蒼翔祭(そうしょうさい)会津大学学園祭の正式名称
学祭蒼翔祭の略称
学祭委員学園祭実行委員(本システムの主要ユーザー)
備品貸出・返却の対象となる物品
備品種別備品を分類するためのタイプ(長机、椅子など)
場所元の場所や移動先(教室、倉庫、露店など)
団体備品を使用する団体
出展許可証団体に発行されるQRコード付き許可証(一括操作で使用)
貸出備品を誰かに渡すこと
返却貸出した備品を戻すこと
在庫利用可能な備品の数
FZ-N1現場で使用するPanasonic製Android端末
Sho-Room学祭委員が使用する統合管理ツール(本システムはそのモジュール)
JWTJSON Web Token(認証トークン)
OTPOne-Time Password(初回登録時の6桁ワンタイムパスワード、有効期限30分)
Service BindingCloudflare Workersの機能連携の仕組み
UUID v7時系列でソート可能なユニークID
IDmFeliCaカードの固有ID
e-POS BridgeEPSON製プリンターとの通信ブリッジサービス

14. 付録

14.1 QRコード仕様

  • 形式: QRコード
  • 内容: 備品UUID(Items.id)
  • 生成: EPSON TM-L90で印刷
  • サイズ: 2cm × 2cm程度

14.2 システム構成フロー図

14.3 データフロー図


変更履歴

日付バージョン変更内容
2026-02-032.8Notion対応: すべての箇条書き(- / 1.)の前に空行を追加。e-POS Bridge仕様を詳細化(JSON印刷形式、エンドポイント、実装例、エラーハンドリング)。TM-L90にUSBポートが存在しない旨を明記
2026-02-032.7BFFパターン(Backend for Frontend)を採用。Cloudflare Pages + Pages Functionsでセキュアな認証を実装。HttpOnly Cookieによる XSS対策。ドメイン構成の明記。Vitestによるテスト仕様を追加。厳密なファイル分割規則を策定
2026-02-032.6学年・学生身分の情報を削除。OTP方式への変更に伴い不要になったAPI・UIを整理。StaffsテーブルとAPIレスポンスから該当フィールドを削除
2026-01-312.5権限変更機能を追加。学祭委員一覧取得API、NFCスキャンによる検索API、権限変更API、AuthorityChangeLogテーブルを追加。Web特殊操作メニューに権限変更機能を実装
2026-01-312.4A2ボタンを概要/詳細切替に使用。物理ボタンの動的割り当て追加。最高管理者向け特殊操作機能追加(IDm再登録、強制状態変更)。AppButtonManager APIに更新
2026-01-312.3FZ-N1の物理ボタン(A1-A3)をToughpad SDKで制御する仕様を追加。スライド操作から物理ボタン操作に変更。画面UIを物理ボタン対応に更新
2026-01-312.2初回登録をCampus Square方式からOTP方式に変更。Discord QRログイン削除。Zod/standard-validatorによるバリデーション追加。学祭準備期間用データエクスポートAPI追加。独自テンキーUI実装。FZ-N1からのプリンター操作を不可に変更(Webのみ)
2026-01-312.1NFCログイン成功時の確認画面を追加。団体許可証→出展許可証に用語変更
2026-01-312.0元仕様書を基に全面改訂。会津大学学園祭向け仕様を明記、NFCログイン詳細、一括操作、Mermaid図、SQLスキーマを追加
2026-01-311.1非機能要件、権限管理、バリデーション、エラー処理、テスト方針を追加
2024-XX-XX1.0初版作成