initial
This commit is contained in:
commit
bde8398d46
30 changed files with 3269 additions and 0 deletions
21
.env.example
Normal file
21
.env.example
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=sqlite://hyperdashi.db
|
||||||
|
# For production: DATABASE_URL=postgres://user:password@localhost/hyperdashi
|
||||||
|
|
||||||
|
# Server
|
||||||
|
SERVER_HOST=127.0.0.1
|
||||||
|
SERVER_PORT=8080
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
STORAGE_TYPE=local
|
||||||
|
LOCAL_STORAGE_PATH=./uploads
|
||||||
|
|
||||||
|
# For S3 storage (production)
|
||||||
|
# STORAGE_TYPE=s3
|
||||||
|
# AWS_ACCESS_KEY_ID=your_access_key
|
||||||
|
# AWS_SECRET_ACCESS_KEY=your_secret_key
|
||||||
|
# AWS_REGION=ap-northeast-1
|
||||||
|
# S3_BUCKET_NAME=hyperdashi-images
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
RUST_LOG=hyperdashi_server=debug,tower_http=debug,sqlx=warn
|
||||||
29
.envrc
Normal file
29
.envrc
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Automatically load the nix environment
|
||||||
|
use flake
|
||||||
|
|
||||||
|
# Load environment variables from .env file if it exists
|
||||||
|
dotenv_if_exists
|
||||||
|
|
||||||
|
# Set development environment variables
|
||||||
|
export DATABASE_URL="sqlite://hyperdashi.db"
|
||||||
|
export RUST_LOG="debug"
|
||||||
|
export RUST_BACKTRACE="1"
|
||||||
|
|
||||||
|
# Server configuration
|
||||||
|
export SERVER_HOST="127.0.0.1"
|
||||||
|
export SERVER_PORT="8081"
|
||||||
|
|
||||||
|
# Storage configuration
|
||||||
|
export STORAGE_TYPE="local"
|
||||||
|
export STORAGE_MAX_FILE_SIZE_MB="10"
|
||||||
|
export LOCAL_STORAGE_PATH="./uploads"
|
||||||
|
|
||||||
|
# Rust development settings
|
||||||
|
export CARGO_TARGET_DIR="target"
|
||||||
|
export RUSTFLAGS="--deny warnings"
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
mkdir -p uploads
|
||||||
|
|
||||||
|
echo "Development environment loaded!"
|
||||||
|
echo "Run 'nix run .#dev' to start the development server"
|
||||||
81
.gitignore
vendored
Normal file
81
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# Rust
|
||||||
|
/target/
|
||||||
|
**/*.rs.bk
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Uploads and temporary files
|
||||||
|
/uploads/
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Nix
|
||||||
|
result
|
||||||
|
result-*
|
||||||
|
.direnv/
|
||||||
|
|
||||||
|
# Node modules (for frontend)
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
.pnpm-debug.log
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.tgz
|
||||||
|
*.tar.gz
|
||||||
|
|
||||||
|
# Coverage reports
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
tarpaulin-report.html
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
*_backup_*
|
||||||
|
*.bak
|
||||||
|
*.backup
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
test.db
|
||||||
|
*test*.db
|
||||||
61
CLAUDE.md
Normal file
61
CLAUDE.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
これは、情報メディアシステム局の物品管理システム「dashi」の発展版・汎用版であるところの「hyperdashi」のバックエンドである。
|
||||||
|
|
||||||
|
dashiはhttps://dashi.sohosai.comから使うことができる。
|
||||||
|
dashiではグラフデータベースと検索エンジンを利用していたが、運用の煩雑さと見合わないことから、RDBに置き換えることを目標としている。
|
||||||
|
|
||||||
|
以下にhyperdashiの要件を述べる。
|
||||||
|
|
||||||
|
物品情報の登録
|
||||||
|
機材の物品情報を登録する。
|
||||||
|
物品名
|
||||||
|
ラベルID
|
||||||
|
これがQRコード/バーコードによって物品に貼り付けられ、このラベルIDを参照することとなる
|
||||||
|
Alphanumeric(使わない文字の制限あり、IとOは1と0に見間違えるので使わない)
|
||||||
|
|
||||||
|
型番
|
||||||
|
備考
|
||||||
|
長さや古さ、傷があるなどを書く
|
||||||
|
購入年度
|
||||||
|
購入金額
|
||||||
|
耐久年数
|
||||||
|
減価償却対象かどうか
|
||||||
|
高い物品を買ったときに考える。過去に買ったものに関しては減価償却対象ではない
|
||||||
|
接続名(端子の名前を書く)
|
||||||
|
可変長配列らしい
|
||||||
|
ケーブル識別色のパターン
|
||||||
|
ケーブルに貼ってある色を端子側から順番に追加する。
|
||||||
|
白に注意!白は良く見えない
|
||||||
|
収納場所
|
||||||
|
複数選べる。なぜなら、部屋AのラックXのコンテナαに収納されているような場合に「部屋A」「ラックX」「コンテナα」と3つ書ければ検索性が良いと言えるから。
|
||||||
|
貸出中か否か
|
||||||
|
QRかバーコードかラベル未貼付か
|
||||||
|
登録日時
|
||||||
|
これは自動で割当たる
|
||||||
|
更新日時
|
||||||
|
これも自動で割当たる
|
||||||
|
廃棄/譲渡済み
|
||||||
|
削除とは別。削除はミスった時用で、廃棄時はこのフラグを立てる
|
||||||
|
画像
|
||||||
|
URLの予定。登録時はファイルが直接上がってくるので、それをオブジェクトストレージにアップロードした結果のURLか、ローカルにフォールバックされた結果のバックエンドのURLが入ることになる。
|
||||||
|
|
||||||
|
部分的な更新も可能、このAPIも必要
|
||||||
|
Excel風のUIから登録・編集を行う予定
|
||||||
|
|
||||||
|
貸出管理のテーブル
|
||||||
|
貸出物品のID
|
||||||
|
誰に貸出中か
|
||||||
|
学籍番号と名前
|
||||||
|
どこに貸出中か
|
||||||
|
String、団体名などが入る予定
|
||||||
|
貸出日時
|
||||||
|
返却日時
|
||||||
|
備考
|
||||||
|
|
||||||
|
貸出し情報も登録・更新(返却時)するためのAPIが必要
|
||||||
|
|
||||||
|
画像はS3系のオブジェクトストレージにアップロードすることを予定しているが、envの設定によってローカルにフォールバックできると嬉しい。
|
||||||
|
RDBも然り。プロダクションではPostgreSQLの予定だが、ローカルのsqliteにフォールバックできると嬉しい(開発時に)。
|
||||||
|
|
||||||
|
サーバはRustで記述し、AxumとSQLxで実装する。
|
||||||
|
クライアントとの間ではREST APIを使ってやり取りを行う。
|
||||||
|
|
||||||
67
Cargo.toml
Normal file
67
Cargo.toml
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
[package]
|
||||||
|
name = "hyperdashi-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Web framework
|
||||||
|
axum = { version = "0.7", features = ["multipart"] }
|
||||||
|
tower = "0.4"
|
||||||
|
tower-http = { version = "0.5", features = ["cors", "trace", "fs"] }
|
||||||
|
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "sqlite", "json", "chrono", "rust_decimal", "migrate"] }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
config = "0.14"
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
thiserror = "1.0"
|
||||||
|
anyhow = "1.0"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
# Date/Time
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
# Decimal for money
|
||||||
|
rust_decimal = { version = "1.35", features = ["serde-float"] }
|
||||||
|
|
||||||
|
# HTTP client (for S3)
|
||||||
|
reqwest = { version = "0.12", features = ["json", "multipart", "rustls-tls"], default-features = false }
|
||||||
|
|
||||||
|
# AWS SDK (for S3)
|
||||||
|
aws-config = "1.1"
|
||||||
|
aws-sdk-s3 = "1.14"
|
||||||
|
|
||||||
|
# File uploads
|
||||||
|
multer = "3.0"
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
validator = { version = "0.18", features = ["derive"] }
|
||||||
|
|
||||||
|
# Regular expressions
|
||||||
|
regex = "1.10"
|
||||||
|
lazy_static = "1.4"
|
||||||
|
once_cell = "1.19"
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
dotenvy = "0.15"
|
||||||
|
|
||||||
|
# UUID for unique identifiers
|
||||||
|
uuid = { version = "1.7", features = ["v4", "serde"] }
|
||||||
|
|
||||||
|
# Async trait
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio-test = "0.4"
|
||||||
345
DESIGN.md
Normal file
345
DESIGN.md
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
# HyperDashi バックエンド詳細設計書
|
||||||
|
|
||||||
|
## 1. システム概要
|
||||||
|
|
||||||
|
HyperDashiは、情報メディアシステム局の物品管理システム「dashi」の発展版・汎用版である。グラフデータベースと検索エンジンを使用していた従来システムから、運用の簡素化を目的としてRDBベースのシステムに移行する。
|
||||||
|
|
||||||
|
### 1.1 技術スタック
|
||||||
|
- **言語**: Rust
|
||||||
|
- **Webフレームワーク**: Axum
|
||||||
|
- **データベース**: PostgreSQL (本番環境) / SQLite (開発環境)
|
||||||
|
- **ORM**: SQLx
|
||||||
|
- **ストレージ**: S3互換オブジェクトストレージ (本番環境) / ローカルファイルシステム (開発環境)
|
||||||
|
- **API形式**: REST API
|
||||||
|
|
||||||
|
## 2. データベース設計
|
||||||
|
|
||||||
|
### 2.1 物品情報テーブル (items)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
label_id VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
model_number VARCHAR(255),
|
||||||
|
remarks TEXT,
|
||||||
|
purchase_year INTEGER,
|
||||||
|
purchase_amount DECIMAL(12, 2),
|
||||||
|
useful_life INTEGER,
|
||||||
|
is_depreciable BOOLEAN DEFAULT FALSE,
|
||||||
|
connection_names TEXT[], -- PostgreSQL配列型、SQLiteではJSON
|
||||||
|
cable_color_pattern TEXT[], -- PostgreSQL配列型、SQLiteではJSON
|
||||||
|
storage_locations TEXT[], -- PostgreSQL配列型、SQLiteではJSON
|
||||||
|
is_on_loan BOOLEAN DEFAULT FALSE,
|
||||||
|
label_type VARCHAR(20) CHECK (label_type IN ('qr', 'barcode', 'none')),
|
||||||
|
is_disposed BOOLEAN DEFAULT FALSE,
|
||||||
|
image_url TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- インデックス
|
||||||
|
CREATE INDEX idx_items_label_id ON items(label_id);
|
||||||
|
CREATE INDEX idx_items_name ON items(name);
|
||||||
|
CREATE INDEX idx_items_is_on_loan ON items(is_on_loan);
|
||||||
|
CREATE INDEX idx_items_is_disposed ON items(is_disposed);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 貸出管理テーブル (loans)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE loans (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id),
|
||||||
|
student_number VARCHAR(20) NOT NULL,
|
||||||
|
student_name VARCHAR(100) NOT NULL,
|
||||||
|
organization VARCHAR(255),
|
||||||
|
loan_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
return_date TIMESTAMP WITH TIME ZONE,
|
||||||
|
remarks TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- インデックス
|
||||||
|
CREATE INDEX idx_loans_item_id ON loans(item_id);
|
||||||
|
CREATE INDEX idx_loans_student_number ON loans(student_number);
|
||||||
|
CREATE INDEX idx_loans_return_date ON loans(return_date);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 画像管理テーブル (images) - オプション
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE images (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
file_name VARCHAR(255) NOT NULL,
|
||||||
|
content_type VARCHAR(100) NOT NULL,
|
||||||
|
storage_type VARCHAR(20) CHECK (storage_type IN ('s3', 'local')),
|
||||||
|
storage_path TEXT NOT NULL,
|
||||||
|
size_bytes BIGINT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. API設計
|
||||||
|
|
||||||
|
### 3.1 物品管理API
|
||||||
|
|
||||||
|
#### 3.1.1 物品一覧取得
|
||||||
|
- **エンドポイント**: `GET /api/v1/items`
|
||||||
|
- **クエリパラメータ**:
|
||||||
|
- `page` (optional): ページ番号
|
||||||
|
- `per_page` (optional): 1ページあたりの件数
|
||||||
|
- `search` (optional): 検索キーワード
|
||||||
|
- `is_on_loan` (optional): 貸出中フィルタ
|
||||||
|
- `is_disposed` (optional): 廃棄済みフィルタ
|
||||||
|
- **レスポンス**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "HDMIケーブル 3m",
|
||||||
|
"label_id": "HYP-A001",
|
||||||
|
"model_number": "HDMI-3M-V2",
|
||||||
|
"remarks": "端子部分に少し傷あり",
|
||||||
|
"purchase_year": 2023,
|
||||||
|
"purchase_amount": 1500.00,
|
||||||
|
"useful_life": 5,
|
||||||
|
"is_depreciable": false,
|
||||||
|
"connection_names": ["HDMI Type-A", "HDMI Type-A"],
|
||||||
|
"cable_color_pattern": ["赤", "青", "赤"],
|
||||||
|
"storage_locations": ["部屋A", "ラックX", "コンテナα"],
|
||||||
|
"is_on_loan": false,
|
||||||
|
"label_type": "qr",
|
||||||
|
"is_disposed": false,
|
||||||
|
"image_url": "https://storage.example.com/images/hdmi-cable.jpg",
|
||||||
|
"created_at": "2023-04-01T10:00:00Z",
|
||||||
|
"updated_at": "2023-04-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 150,
|
||||||
|
"page": 1,
|
||||||
|
"per_page": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.1.2 物品詳細取得
|
||||||
|
- **エンドポイント**: `GET /api/v1/items/{id}`
|
||||||
|
- **レスポンス**: 単一の物品オブジェクト
|
||||||
|
|
||||||
|
#### 3.1.3 物品登録
|
||||||
|
- **エンドポイント**: `POST /api/v1/items`
|
||||||
|
- **リクエストボディ**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "HDMIケーブル 3m",
|
||||||
|
"label_id": "HYP-A001",
|
||||||
|
"model_number": "HDMI-3M-V2",
|
||||||
|
"remarks": "端子部分に少し傷あり",
|
||||||
|
"purchase_year": 2023,
|
||||||
|
"purchase_amount": 1500.00,
|
||||||
|
"useful_life": 5,
|
||||||
|
"is_depreciable": false,
|
||||||
|
"connection_names": ["HDMI Type-A", "HDMI Type-A"],
|
||||||
|
"cable_color_pattern": ["赤", "青", "赤"],
|
||||||
|
"storage_locations": ["部屋A", "ラックX", "コンテナα"],
|
||||||
|
"label_type": "qr"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.1.4 物品更新
|
||||||
|
- **エンドポイント**: `PUT /api/v1/items/{id}`
|
||||||
|
- **リクエストボディ**: 物品登録と同じ(全項目更新)
|
||||||
|
|
||||||
|
#### 3.1.5 物品部分更新
|
||||||
|
- **エンドポイント**: `PATCH /api/v1/items/{id}`
|
||||||
|
- **リクエストボディ**: 更新したいフィールドのみ
|
||||||
|
|
||||||
|
#### 3.1.6 物品削除
|
||||||
|
- **エンドポイント**: `DELETE /api/v1/items/{id}`
|
||||||
|
- **説明**: 論理削除ではなく物理削除(ミス入力の修正用)
|
||||||
|
|
||||||
|
#### 3.1.7 物品廃棄/譲渡
|
||||||
|
- **エンドポイント**: `POST /api/v1/items/{id}/dispose`
|
||||||
|
- **説明**: is_disposedフラグを立てる
|
||||||
|
|
||||||
|
#### 3.1.8 ラベルIDによる物品検索
|
||||||
|
- **エンドポイント**: `GET /api/v1/items/by-label/{label_id}`
|
||||||
|
- **説明**: QRコード/バーコード読み取り時の検索用
|
||||||
|
|
||||||
|
### 3.2 貸出管理API
|
||||||
|
|
||||||
|
#### 3.2.1 貸出登録
|
||||||
|
- **エンドポイント**: `POST /api/v1/loans`
|
||||||
|
- **リクエストボディ**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"item_id": 1,
|
||||||
|
"student_number": "21001234",
|
||||||
|
"student_name": "山田太郎",
|
||||||
|
"organization": "第74回総合祭実行委員会",
|
||||||
|
"remarks": "イベント用機材"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2.2 返却処理
|
||||||
|
- **エンドポイント**: `POST /api/v1/loans/{id}/return`
|
||||||
|
- **リクエストボディ**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"return_date": "2023-04-10T15:00:00Z",
|
||||||
|
"remarks": "問題なく返却"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2.3 貸出履歴取得
|
||||||
|
- **エンドポイント**: `GET /api/v1/loans`
|
||||||
|
- **クエリパラメータ**:
|
||||||
|
- `item_id` (optional): 物品ID
|
||||||
|
- `student_number` (optional): 学籍番号
|
||||||
|
- `active_only` (optional): 未返却のみ
|
||||||
|
|
||||||
|
#### 3.2.4 貸出詳細取得
|
||||||
|
- **エンドポイント**: `GET /api/v1/loans/{id}`
|
||||||
|
|
||||||
|
### 3.3 画像アップロードAPI
|
||||||
|
|
||||||
|
#### 3.3.1 画像アップロード
|
||||||
|
- **エンドポイント**: `POST /api/v1/images/upload`
|
||||||
|
- **Content-Type**: `multipart/form-data`
|
||||||
|
- **レスポンス**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "https://storage.example.com/images/abc123.jpg"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 一括操作API
|
||||||
|
|
||||||
|
#### 3.4.1 物品一括登録
|
||||||
|
- **エンドポイント**: `POST /api/v1/items/bulk`
|
||||||
|
- **リクエストボディ**: 物品オブジェクトの配列
|
||||||
|
|
||||||
|
#### 3.4.2 物品一括更新
|
||||||
|
- **エンドポイント**: `PUT /api/v1/items/bulk`
|
||||||
|
- **リクエストボディ**: 更新対象の物品オブジェクトの配列
|
||||||
|
|
||||||
|
## 4. アプリケーション構造
|
||||||
|
|
||||||
|
### 4.1 ディレクトリ構造
|
||||||
|
```
|
||||||
|
hyperdashi-server/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.rs # エントリーポイント
|
||||||
|
│ ├── config.rs # 設定管理
|
||||||
|
│ ├── db/
|
||||||
|
│ │ ├── mod.rs # データベース接続管理
|
||||||
|
│ │ └── migrations/ # SQLマイグレーション
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ ├── item.rs # 物品モデル
|
||||||
|
│ │ └── loan.rs # 貸出モデル
|
||||||
|
│ ├── handlers/
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ ├── items.rs # 物品ハンドラー
|
||||||
|
│ │ ├── loans.rs # 貸出ハンドラー
|
||||||
|
│ │ └── images.rs # 画像ハンドラー
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ ├── item_service.rs # 物品ビジネスロジック
|
||||||
|
│ │ ├── loan_service.rs # 貸出ビジネスロジック
|
||||||
|
│ │ └── storage.rs # ストレージ抽象化
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ ├── validation.rs # バリデーション
|
||||||
|
│ │ └── label.rs # ラベルID生成
|
||||||
|
│ └── error.rs # エラー型定義
|
||||||
|
├── Cargo.toml
|
||||||
|
├── .env.example
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 主要コンポーネント
|
||||||
|
|
||||||
|
#### 4.2.1 設定管理 (config.rs)
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub database_url: String,
|
||||||
|
pub server_host: String,
|
||||||
|
pub server_port: u16,
|
||||||
|
pub storage_type: StorageType,
|
||||||
|
pub s3_config: Option<S3Config>,
|
||||||
|
pub local_storage_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub enum StorageType {
|
||||||
|
S3,
|
||||||
|
Local,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2.2 ストレージ抽象化 (storage.rs)
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Storage: Send + Sync {
|
||||||
|
async fn upload(&self, data: Vec<u8>, filename: &str) -> Result<String>;
|
||||||
|
async fn delete(&self, url: &str) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct S3Storage { /* ... */ }
|
||||||
|
pub struct LocalStorage { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. セキュリティ考慮事項
|
||||||
|
|
||||||
|
### 5.1 認証・認可
|
||||||
|
- 初期版では認証なし(内部システムのため)
|
||||||
|
- 将来的にはJWT等による認証を実装予定
|
||||||
|
|
||||||
|
### 5.2 入力検証
|
||||||
|
- ラベルIDの形式検証(英数字、I/O除外)
|
||||||
|
- SQLインジェクション対策(SQLx使用)
|
||||||
|
- ファイルアップロードのサイズ制限とMIMEタイプ検証
|
||||||
|
|
||||||
|
### 5.3 データ保護
|
||||||
|
- 個人情報(学籍番号、氏名)の適切な管理
|
||||||
|
- HTTPSによる通信の暗号化
|
||||||
|
|
||||||
|
## 6. パフォーマンス最適化
|
||||||
|
|
||||||
|
### 6.1 データベース
|
||||||
|
- 適切なインデックスの設定
|
||||||
|
- N+1問題の回避
|
||||||
|
- コネクションプーリング
|
||||||
|
|
||||||
|
### 6.2 画像処理
|
||||||
|
- 画像のリサイズとサムネイル生成
|
||||||
|
- CDN利用による配信最適化(将来)
|
||||||
|
|
||||||
|
### 6.3 キャッシング
|
||||||
|
- 頻繁にアクセスされる物品情報のキャッシング(将来)
|
||||||
|
|
||||||
|
## 7. 運用・保守
|
||||||
|
|
||||||
|
### 7.1 ロギング
|
||||||
|
- 構造化ログの出力
|
||||||
|
- エラートラッキング
|
||||||
|
|
||||||
|
### 7.2 モニタリング
|
||||||
|
- ヘルスチェックエンドポイント
|
||||||
|
- メトリクス収集(将来)
|
||||||
|
|
||||||
|
### 7.3 バックアップ
|
||||||
|
- データベースの定期バックアップ
|
||||||
|
- 画像データのバックアップ
|
||||||
|
|
||||||
|
## 8. 今後の拡張予定
|
||||||
|
|
||||||
|
- 認証・認可機能
|
||||||
|
- 物品の予約機能
|
||||||
|
- 統計・レポート機能
|
||||||
|
- モバイルアプリ対応API
|
||||||
|
- WebSocket による リアルタイム更新
|
||||||
82
flake.lock
generated
Normal file
82
flake.lock
generated
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1751271578,
|
||||||
|
"narHash": "sha256-P/SQmKDu06x8yv7i0s8bvnnuJYkxVGBWLWHaU+tt4YY=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "3016b4b15d13f3089db8a41ef937b13a9e33a8df",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1751596734,
|
||||||
|
"narHash": "sha256-1tQOwmn3jEUQjH0WDJyklC+hR7Bj+iqx6ChtRX2QiPA=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "e28ba067a9368286a8bc88b68dc2ca92181a09f0",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
277
flake.nix
Normal file
277
flake.nix
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
{
|
||||||
|
description = "HyperDashi Backend Server Development Environment";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
rust-overlay = {
|
||||||
|
url = "github:oxalica/rust-overlay";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
overlays = [ (import rust-overlay) ];
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system overlays;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Rust toolchain specification
|
||||||
|
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||||
|
extensions = [ "rust-src" "rustfmt" "clippy" "rust-analyzer" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# Node.js for frontend development
|
||||||
|
nodejs = pkgs.nodejs_20;
|
||||||
|
|
||||||
|
# Database and development tools
|
||||||
|
developmentTools = with pkgs; [
|
||||||
|
# Database tools
|
||||||
|
sqlite
|
||||||
|
sqlx-cli
|
||||||
|
|
||||||
|
# Development utilities
|
||||||
|
git
|
||||||
|
curl
|
||||||
|
jq
|
||||||
|
|
||||||
|
# Text editors and IDE support
|
||||||
|
vim
|
||||||
|
nano
|
||||||
|
|
||||||
|
# Build tools
|
||||||
|
pkg-config
|
||||||
|
openssl
|
||||||
|
|
||||||
|
# Optional: GUI tools if available
|
||||||
|
dbeaver-bin
|
||||||
|
];
|
||||||
|
|
||||||
|
# Runtime dependencies
|
||||||
|
runtimeDeps = with pkgs; [
|
||||||
|
# SSL/TLS support
|
||||||
|
openssl
|
||||||
|
|
||||||
|
# SQLite runtime
|
||||||
|
sqlite
|
||||||
|
|
||||||
|
# Network tools for testing
|
||||||
|
netcat-gnu
|
||||||
|
];
|
||||||
|
|
||||||
|
# Development shell packages
|
||||||
|
shellPackages = [
|
||||||
|
rustToolchain
|
||||||
|
nodejs
|
||||||
|
pkgs.yarn
|
||||||
|
pkgs.pnpm
|
||||||
|
] ++ developmentTools ++ runtimeDeps;
|
||||||
|
|
||||||
|
in
|
||||||
|
{
|
||||||
|
# Development shell
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
buildInputs = shellPackages;
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
shellHook = ''
|
||||||
|
echo "🦀 HyperDashi Development Environment"
|
||||||
|
echo "=================================================="
|
||||||
|
echo "Rust version: $(rustc --version)"
|
||||||
|
echo "Node.js version: $(node --version)"
|
||||||
|
echo "SQLite version: $(sqlite3 --version)"
|
||||||
|
echo "=================================================="
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
export DATABASE_URL="sqlite://hyperdashi.db"
|
||||||
|
export RUST_LOG="debug"
|
||||||
|
export RUST_BACKTRACE=1
|
||||||
|
|
||||||
|
# Server configuration
|
||||||
|
export SERVER_HOST="127.0.0.1"
|
||||||
|
export SERVER_PORT="8081"
|
||||||
|
|
||||||
|
# Storage configuration
|
||||||
|
export STORAGE_TYPE="local"
|
||||||
|
export STORAGE_MAX_FILE_SIZE_MB="10"
|
||||||
|
export LOCAL_STORAGE_PATH="./uploads"
|
||||||
|
|
||||||
|
# Create uploads directory if it doesn't exist
|
||||||
|
mkdir -p uploads
|
||||||
|
|
||||||
|
echo "Environment variables set:"
|
||||||
|
echo " DATABASE_URL: $DATABASE_URL"
|
||||||
|
echo " SERVER_PORT: $SERVER_PORT"
|
||||||
|
echo " STORAGE_MAX_FILE_SIZE_MB: $STORAGE_MAX_FILE_SIZE_MB"
|
||||||
|
echo ""
|
||||||
|
echo "Available commands:"
|
||||||
|
echo " cargo build - Build the project"
|
||||||
|
echo " cargo run - Run the development server"
|
||||||
|
echo " cargo test - Run tests"
|
||||||
|
echo " sqlx migrate run - Run database migrations"
|
||||||
|
echo " nix run .#setup-db - Initial database setup"
|
||||||
|
echo " nix run .#dev - Start development server"
|
||||||
|
echo " nix run .#test - Run all tests"
|
||||||
|
echo ""
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Additional environment variables for development
|
||||||
|
DATABASE_URL = "sqlite://hyperdashi.db";
|
||||||
|
RUST_LOG = "debug";
|
||||||
|
RUST_BACKTRACE = "1";
|
||||||
|
|
||||||
|
# PKG_CONFIG_PATH for OpenSSL
|
||||||
|
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
|
||||||
|
|
||||||
|
# Library paths
|
||||||
|
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
|
||||||
|
pkgs.openssl
|
||||||
|
pkgs.sqlite
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# Package outputs
|
||||||
|
packages = {
|
||||||
|
# Backend binary
|
||||||
|
hyperdashi-server = pkgs.rustPlatform.buildRustPackage {
|
||||||
|
pname = "hyperdashi-server";
|
||||||
|
version = "0.1.0";
|
||||||
|
|
||||||
|
src = ./.;
|
||||||
|
|
||||||
|
cargoLock = {
|
||||||
|
lockFile = ./Cargo.lock;
|
||||||
|
};
|
||||||
|
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
pkg-config
|
||||||
|
rustToolchain
|
||||||
|
];
|
||||||
|
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
openssl
|
||||||
|
sqlite
|
||||||
|
];
|
||||||
|
|
||||||
|
# Skip tests during build (can be run separately)
|
||||||
|
doCheck = false;
|
||||||
|
|
||||||
|
meta = with pkgs.lib; {
|
||||||
|
description = "HyperDashi equipment management system backend";
|
||||||
|
license = licenses.mit;
|
||||||
|
maintainers = [ ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Docker image
|
||||||
|
docker-image = pkgs.dockerTools.buildImage {
|
||||||
|
name = "hyperdashi-server";
|
||||||
|
tag = "latest";
|
||||||
|
|
||||||
|
contents = [
|
||||||
|
self.packages.${system}.hyperdashi-server
|
||||||
|
pkgs.sqlite
|
||||||
|
pkgs.openssl
|
||||||
|
];
|
||||||
|
|
||||||
|
config = {
|
||||||
|
Cmd = [ "${self.packages.${system}.hyperdashi-server}/bin/hyperdashi-server" ];
|
||||||
|
Env = [
|
||||||
|
"DATABASE_URL=sqlite:///data/hyperdashi.db"
|
||||||
|
"SERVER_HOST=0.0.0.0"
|
||||||
|
"SERVER_PORT=8080"
|
||||||
|
"STORAGE_TYPE=local"
|
||||||
|
"LOCAL_STORAGE_PATH=/uploads"
|
||||||
|
];
|
||||||
|
ExposedPorts = {
|
||||||
|
"8080/tcp" = {};
|
||||||
|
};
|
||||||
|
Volumes = {
|
||||||
|
"/data" = {};
|
||||||
|
"/uploads" = {};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
default = self.packages.${system}.hyperdashi-server;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Formatter
|
||||||
|
formatter = pkgs.nixpkgs-fmt;
|
||||||
|
|
||||||
|
# Apps for easy running
|
||||||
|
apps = {
|
||||||
|
# Run the server
|
||||||
|
hyperdashi-server = flake-utils.lib.mkApp {
|
||||||
|
drv = self.packages.${system}.hyperdashi-server;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Development server with auto-reload
|
||||||
|
dev = flake-utils.lib.mkApp {
|
||||||
|
drv = pkgs.writeShellScriptBin "hyperdashi-dev" ''
|
||||||
|
export DATABASE_URL="sqlite://hyperdashi.db"
|
||||||
|
export RUST_LOG="debug"
|
||||||
|
|
||||||
|
echo "Starting HyperDashi development server..."
|
||||||
|
echo "Server will be available at http://localhost:8081"
|
||||||
|
|
||||||
|
# Run migrations first
|
||||||
|
sqlx migrate run
|
||||||
|
|
||||||
|
# Start the server with cargo watch for auto-reload
|
||||||
|
if command -v cargo-watch >/dev/null 2>&1; then
|
||||||
|
cargo watch -x run
|
||||||
|
else
|
||||||
|
echo "cargo-watch not found, installing..."
|
||||||
|
cargo install cargo-watch
|
||||||
|
cargo watch -x run
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# Database setup
|
||||||
|
setup-db = flake-utils.lib.mkApp {
|
||||||
|
drv = pkgs.writeShellScriptBin "setup-db" ''
|
||||||
|
export DATABASE_URL="sqlite://hyperdashi.db"
|
||||||
|
|
||||||
|
echo "Setting up HyperDashi database..."
|
||||||
|
|
||||||
|
# Create database file if it doesn't exist
|
||||||
|
touch hyperdashi.db
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
sqlx migrate run
|
||||||
|
|
||||||
|
echo "Database setup complete!"
|
||||||
|
echo "Database file: $(pwd)/hyperdashi.db"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
test = flake-utils.lib.mkApp {
|
||||||
|
drv = pkgs.writeShellScriptBin "hyperdashi-test" ''
|
||||||
|
export DATABASE_URL="sqlite://test.db"
|
||||||
|
export RUST_LOG="info"
|
||||||
|
|
||||||
|
echo "Running HyperDashi tests..."
|
||||||
|
|
||||||
|
# Clean up any existing test database
|
||||||
|
rm -f test.db
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Clean up test database
|
||||||
|
rm -f test.db
|
||||||
|
|
||||||
|
echo "Tests completed!"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
default = self.apps.${system}.hyperdashi-server;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
27
migrations/20240704000001_create_items_table.sql
Normal file
27
migrations/20240704000001_create_items_table.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
-- Create items table (compatible with both PostgreSQL and SQLite)
|
||||||
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
label_id TEXT UNIQUE NOT NULL,
|
||||||
|
model_number TEXT,
|
||||||
|
remarks TEXT,
|
||||||
|
purchase_year INTEGER,
|
||||||
|
purchase_amount REAL,
|
||||||
|
durability_years INTEGER,
|
||||||
|
is_depreciation_target BOOLEAN DEFAULT FALSE,
|
||||||
|
connection_names TEXT, -- JSON array for both DBs
|
||||||
|
cable_color_pattern TEXT, -- JSON array for both DBs
|
||||||
|
storage_locations TEXT, -- JSON array for both DBs
|
||||||
|
is_on_loan BOOLEAN DEFAULT FALSE,
|
||||||
|
qr_code_type TEXT CHECK (qr_code_type IN ('qr', 'barcode', 'none')),
|
||||||
|
is_disposed BOOLEAN DEFAULT FALSE,
|
||||||
|
image_url TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_label_id ON items(label_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_name ON items(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_is_on_loan ON items(is_on_loan);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_is_disposed ON items(is_disposed);
|
||||||
18
migrations/20240704000002_create_loans_table.sql
Normal file
18
migrations/20240704000002_create_loans_table.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
-- Create loans table (compatible with both PostgreSQL and SQLite)
|
||||||
|
CREATE TABLE IF NOT EXISTS loans (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id),
|
||||||
|
student_number TEXT NOT NULL,
|
||||||
|
student_name TEXT NOT NULL,
|
||||||
|
organization TEXT,
|
||||||
|
loan_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
return_date TIMESTAMP,
|
||||||
|
remarks TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_loans_item_id ON loans(item_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_loans_student_number ON loans(student_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_loans_return_date ON loans(return_date);
|
||||||
24
migrations/20250705000001_create_cable_colors_table.sql
Normal file
24
migrations/20250705000001_create_cable_colors_table.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
-- Create cable colors table
|
||||||
|
CREATE TABLE cable_colors (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
hex_code VARCHAR(7),
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert common cable colors
|
||||||
|
INSERT INTO cable_colors (name, hex_code, description) VALUES
|
||||||
|
('赤', '#FF0000', '赤色'),
|
||||||
|
('青', '#0000FF', '青色'),
|
||||||
|
('緑', '#00FF00', '緑色'),
|
||||||
|
('黄', '#FFFF00', '黄色'),
|
||||||
|
('黒', '#000000', '黒色'),
|
||||||
|
('白', '#FFFFFF', '白色'),
|
||||||
|
('グレー', '#808080', 'グレー色'),
|
||||||
|
('オレンジ', '#FFA500', 'オレンジ色'),
|
||||||
|
('紫', '#800080', '紫色'),
|
||||||
|
('茶', '#A52A2A', '茶色'),
|
||||||
|
('ピンク', '#FFC0CB', 'ピンク色'),
|
||||||
|
('シルバー', '#C0C0C0', 'シルバー色');
|
||||||
152
src/config.rs
Normal file
152
src/config.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
use config::{Config as ConfigBuilder, ConfigError, Environment, File};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub database: DatabaseConfig,
|
||||||
|
pub server: ServerConfig,
|
||||||
|
pub storage: StorageConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct DatabaseConfig {
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct StorageConfig {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub storage_type: StorageType,
|
||||||
|
pub local: Option<LocalStorageConfig>,
|
||||||
|
pub s3: Option<S3Config>,
|
||||||
|
#[serde(default = "default_max_file_size")]
|
||||||
|
pub max_file_size_mb: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_file_size() -> u64 {
|
||||||
|
5 // Default 5MB
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum StorageType {
|
||||||
|
Local,
|
||||||
|
S3,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct LocalStorageConfig {
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct S3Config {
|
||||||
|
pub bucket_name: String,
|
||||||
|
pub region: String,
|
||||||
|
pub access_key_id: Option<String>,
|
||||||
|
pub secret_access_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn new() -> Result<Self, ConfigError> {
|
||||||
|
let run_mode = env::var("RUN_MODE").unwrap_or_else(|_| "development".into());
|
||||||
|
|
||||||
|
let s = ConfigBuilder::builder()
|
||||||
|
// Start off by merging in the "default" configuration file
|
||||||
|
.add_source(File::with_name("config/default").required(false))
|
||||||
|
// Add in the current environment file
|
||||||
|
// Default to 'development' env
|
||||||
|
.add_source(File::with_name(&format!("config/{}", run_mode)).required(false))
|
||||||
|
// Add in a local configuration file
|
||||||
|
// This file shouldn't be checked in to git
|
||||||
|
.add_source(File::with_name("config/local").required(false))
|
||||||
|
// Add in settings from the environment (with a prefix of HYPERDASHI)
|
||||||
|
// Eg.. `HYPERDASHI_DEBUG=1 ./target/app` would set the `debug` key
|
||||||
|
.add_source(Environment::with_prefix("HYPERDASHI").separator("_"))
|
||||||
|
// You can override settings from env variables
|
||||||
|
.add_source(
|
||||||
|
Environment::default()
|
||||||
|
.try_parsing(true)
|
||||||
|
.separator("_")
|
||||||
|
.list_separator(" ")
|
||||||
|
)
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
// You can deserialize (and thus freeze) the entire configuration as
|
||||||
|
s.try_deserialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_env() -> Result<Self, ConfigError> {
|
||||||
|
// Load .env file if it exists
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
let database_url = env::var("DATABASE_URL")
|
||||||
|
.unwrap_or_else(|_| "sqlite://hyperdashi.db".to_string());
|
||||||
|
|
||||||
|
let server_host = env::var("SERVER_HOST")
|
||||||
|
.unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||||
|
|
||||||
|
let server_port = env::var("SERVER_PORT")
|
||||||
|
.unwrap_or_else(|_| "8080".to_string())
|
||||||
|
.parse::<u16>()
|
||||||
|
.unwrap_or(8080);
|
||||||
|
|
||||||
|
let storage_type = env::var("STORAGE_TYPE")
|
||||||
|
.unwrap_or_else(|_| "local".to_string());
|
||||||
|
|
||||||
|
let max_file_size_mb = env::var("STORAGE_MAX_FILE_SIZE_MB")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(5);
|
||||||
|
|
||||||
|
let storage = match storage_type.to_lowercase().as_str() {
|
||||||
|
"s3" => {
|
||||||
|
let bucket_name = env::var("S3_BUCKET_NAME")
|
||||||
|
.map_err(|_| ConfigError::Message("S3_BUCKET_NAME not set".to_string()))?;
|
||||||
|
let region = env::var("AWS_REGION")
|
||||||
|
.map_err(|_| ConfigError::Message("AWS_REGION not set".to_string()))?;
|
||||||
|
let access_key_id = env::var("AWS_ACCESS_KEY_ID").ok();
|
||||||
|
let secret_access_key = env::var("AWS_SECRET_ACCESS_KEY").ok();
|
||||||
|
|
||||||
|
StorageConfig {
|
||||||
|
storage_type: StorageType::S3,
|
||||||
|
local: None,
|
||||||
|
s3: Some(S3Config {
|
||||||
|
bucket_name,
|
||||||
|
region,
|
||||||
|
access_key_id,
|
||||||
|
secret_access_key,
|
||||||
|
}),
|
||||||
|
max_file_size_mb,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let path = env::var("LOCAL_STORAGE_PATH")
|
||||||
|
.unwrap_or_else(|_| "./uploads".to_string());
|
||||||
|
|
||||||
|
StorageConfig {
|
||||||
|
storage_type: StorageType::Local,
|
||||||
|
local: Some(LocalStorageConfig { path }),
|
||||||
|
s3: None,
|
||||||
|
max_file_size_mb,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Config {
|
||||||
|
database: DatabaseConfig { url: database_url },
|
||||||
|
server: ServerConfig {
|
||||||
|
host: server_host,
|
||||||
|
port: server_port,
|
||||||
|
},
|
||||||
|
storage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/db/mod.rs
Normal file
85
src/db/mod.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||||
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::error::AppResult;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum DatabasePool {
|
||||||
|
Postgres(PgPool),
|
||||||
|
Sqlite(SqlitePool),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DatabasePool {
|
||||||
|
pub async fn new(config: &Config) -> AppResult<Self> {
|
||||||
|
let database_url = &config.database.url;
|
||||||
|
|
||||||
|
if database_url.starts_with("postgres://") || database_url.starts_with("postgresql://") {
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(10)
|
||||||
|
.connect(database_url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(DatabasePool::Postgres(pool))
|
||||||
|
} else if database_url.starts_with("sqlite://") {
|
||||||
|
let options = SqliteConnectOptions::from_str(database_url)?
|
||||||
|
.create_if_missing(true);
|
||||||
|
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(10)
|
||||||
|
.connect_with(options)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(DatabasePool::Sqlite(pool))
|
||||||
|
} else {
|
||||||
|
Err(crate::error::AppError::ConfigError(
|
||||||
|
config::ConfigError::Message(
|
||||||
|
"Invalid database URL. Must start with postgres:// or sqlite://".to_string()
|
||||||
|
)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn migrate(&self) -> AppResult<()> {
|
||||||
|
match self {
|
||||||
|
DatabasePool::Postgres(pool) => {
|
||||||
|
sqlx::migrate!("./migrations")
|
||||||
|
.run(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::DatabaseError(sqlx::Error::Migrate(Box::new(e))))?;
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
sqlx::migrate!("./migrations")
|
||||||
|
.run(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::error::AppError::DatabaseError(sqlx::Error::Migrate(Box::new(e))))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn postgres(&self) -> Option<&PgPool> {
|
||||||
|
match self {
|
||||||
|
DatabasePool::Postgres(pool) => Some(pool),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sqlite(&self) -> Option<&SqlitePool> {
|
||||||
|
match self {
|
||||||
|
DatabasePool::Sqlite(pool) => Some(pool),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! query_as {
|
||||||
|
($query:expr, $pool:expr) => {
|
||||||
|
match $pool {
|
||||||
|
$crate::db::DatabasePool::Postgres(pool) => sqlx::query_as!($query).fetch_all(pool).await,
|
||||||
|
$crate::db::DatabasePool::Sqlite(pool) => sqlx::query_as!($query).fetch_all(pool).await,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
101
src/error.rs
Normal file
101
src/error.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AppError {
|
||||||
|
NotFound(String),
|
||||||
|
BadRequest(String),
|
||||||
|
InternalServerError(String),
|
||||||
|
DatabaseError(sqlx::Error),
|
||||||
|
ConfigError(config::ConfigError),
|
||||||
|
IoError(std::io::Error),
|
||||||
|
ValidationError(String),
|
||||||
|
StorageError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for AppError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
AppError::NotFound(msg) => write!(f, "Not found: {}", msg),
|
||||||
|
AppError::BadRequest(msg) => write!(f, "Bad request: {}", msg),
|
||||||
|
AppError::InternalServerError(msg) => write!(f, "Internal server error: {}", msg),
|
||||||
|
AppError::DatabaseError(err) => write!(f, "Database error: {}", err),
|
||||||
|
AppError::ConfigError(err) => write!(f, "Configuration error: {}", err),
|
||||||
|
AppError::IoError(err) => write!(f, "IO error: {}", err),
|
||||||
|
AppError::ValidationError(msg) => write!(f, "Validation error: {}", msg),
|
||||||
|
AppError::StorageError(msg) => write!(f, "Storage error: {}", msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for AppError {}
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, error_message) = match self {
|
||||||
|
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
|
||||||
|
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||||
|
AppError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||||
|
AppError::InternalServerError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
|
||||||
|
AppError::DatabaseError(ref err) => {
|
||||||
|
tracing::error!("Database error: {:?}", err);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Database error occurred".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AppError::ConfigError(ref err) => {
|
||||||
|
tracing::error!("Config error: {:?}", err);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Configuration error occurred".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AppError::IoError(ref err) => {
|
||||||
|
tracing::error!("IO error: {:?}", err);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"IO error occurred".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AppError::StorageError(ref msg) => {
|
||||||
|
tracing::error!("Storage error: {}", msg);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Storage error occurred".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = Json(json!({
|
||||||
|
"error": error_message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
(status, body).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for AppError {
|
||||||
|
fn from(err: sqlx::Error) -> Self {
|
||||||
|
AppError::DatabaseError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<config::ConfigError> for AppError {
|
||||||
|
fn from(err: config::ConfigError) -> Self {
|
||||||
|
AppError::ConfigError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for AppError {
|
||||||
|
fn from(err: std::io::Error) -> Self {
|
||||||
|
AppError::IoError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type AppResult<T> = Result<T, AppError>;
|
||||||
78
src/handlers/cable_colors.rs
Normal file
78
src/handlers/cable_colors.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use crate::error::AppResult;
|
||||||
|
use crate::models::{CableColor, CableColorsListResponse, CreateCableColorRequest, UpdateCableColorRequest};
|
||||||
|
use crate::services::{CableColorService, ItemService, LoanService, StorageService};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CableColorsQuery {
|
||||||
|
#[serde(default = "default_page")]
|
||||||
|
pub page: u32,
|
||||||
|
#[serde(default = "default_per_page")]
|
||||||
|
pub per_page: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_page() -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_per_page() -> u32 {
|
||||||
|
20
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_cable_colors(
|
||||||
|
State((cable_color_service, _item_service, _loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
Query(params): Query<CableColorsQuery>,
|
||||||
|
) -> AppResult<Json<CableColorsListResponse>> {
|
||||||
|
let response = cable_color_service
|
||||||
|
.list_cable_colors(params.page, params.per_page)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_cable_color(
|
||||||
|
State((cable_color_service, _item_service, _loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> AppResult<Json<CableColor>> {
|
||||||
|
let cable_color = cable_color_service.get_cable_color(id).await?;
|
||||||
|
Ok(Json(cable_color))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_cable_color(
|
||||||
|
State((cable_color_service, _item_service, _loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
Json(req): Json<CreateCableColorRequest>,
|
||||||
|
) -> AppResult<(StatusCode, Json<CableColor>)> {
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| crate::error::AppError::ValidationError(e.to_string()))?;
|
||||||
|
|
||||||
|
let cable_color = cable_color_service.create_cable_color(req).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(cable_color)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_cable_color(
|
||||||
|
State((cable_color_service, _item_service, _loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(req): Json<UpdateCableColorRequest>,
|
||||||
|
) -> AppResult<Json<CableColor>> {
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| crate::error::AppError::ValidationError(e.to_string()))?;
|
||||||
|
|
||||||
|
let cable_color = cable_color_service.update_cable_color(id, req).await?;
|
||||||
|
Ok(Json(cable_color))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_cable_color(
|
||||||
|
State((cable_color_service, _item_service, _loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
|
cable_color_service.delete_cable_color(id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
87
src/handlers/images.rs
Normal file
87
src/handlers/images.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Multipart, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::error::AppResult;
|
||||||
|
use crate::services::{ItemService, LoanService, StorageService, CableColorService};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ImageUploadResponse {
|
||||||
|
pub url: String,
|
||||||
|
pub filename: String,
|
||||||
|
pub size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upload_image(
|
||||||
|
State((_cable_color_service, _item_service, _loan_service, storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> AppResult<(StatusCode, Json<ImageUploadResponse>)> {
|
||||||
|
while let Some(field) = multipart.next_field().await.map_err(|e| {
|
||||||
|
crate::error::AppError::BadRequest(format!("Failed to read multipart field: {}", e))
|
||||||
|
})? {
|
||||||
|
let name = field.name().unwrap_or("").to_string();
|
||||||
|
|
||||||
|
if name == "image" {
|
||||||
|
let filename = field.file_name()
|
||||||
|
.ok_or_else(|| crate::error::AppError::BadRequest("No filename provided".to_string()))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let content_type = field.content_type()
|
||||||
|
.unwrap_or("application/octet-stream")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// 画像ファイルの検証
|
||||||
|
if !is_image_content_type(&content_type) {
|
||||||
|
return Err(crate::error::AppError::BadRequest(
|
||||||
|
"Only image files are allowed (JPEG, PNG, GIF, WebP)".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = field.bytes().await.map_err(|e| {
|
||||||
|
crate::error::AppError::BadRequest(format!("Failed to read file data: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// ファイルサイズ制限 (5MB)
|
||||||
|
const MAX_FILE_SIZE: usize = 5 * 1024 * 1024;
|
||||||
|
if data.len() > MAX_FILE_SIZE {
|
||||||
|
return Err(crate::error::AppError::BadRequest(
|
||||||
|
"File size exceeds 5MB limit".to_string()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ユニークなファイル名を生成
|
||||||
|
let unique_filename = generate_unique_filename(&filename);
|
||||||
|
|
||||||
|
// ストレージにアップロード
|
||||||
|
let url = storage_service.upload(data.to_vec(), &unique_filename, &content_type).await?;
|
||||||
|
|
||||||
|
return Ok((StatusCode::CREATED, Json(ImageUploadResponse {
|
||||||
|
url,
|
||||||
|
filename: unique_filename,
|
||||||
|
size: data.len(),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(crate::error::AppError::BadRequest("No image field found in multipart data".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_image_content_type(content_type: &str) -> bool {
|
||||||
|
matches!(content_type,
|
||||||
|
"image/jpeg" | "image/jpg" | "image/png" | "image/gif" | "image/webp"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_unique_filename(original_filename: &str) -> String {
|
||||||
|
let timestamp = chrono::Utc::now().timestamp_millis();
|
||||||
|
let extension = std::path::Path::new(original_filename)
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.unwrap_or("jpg");
|
||||||
|
|
||||||
|
format!("{}_{}.{}", timestamp, uuid::Uuid::new_v4(), extension)
|
||||||
|
}
|
||||||
130
src/handlers/items.rs
Normal file
130
src/handlers/items.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use crate::error::AppResult;
|
||||||
|
use crate::models::{CreateItemRequest, Item, ItemsListResponse, UpdateItemRequest};
|
||||||
|
use crate::services::{ItemService, LoanService, StorageService, CableColorService};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ItemsQuery {
|
||||||
|
#[serde(default = "default_page")]
|
||||||
|
pub page: u32,
|
||||||
|
#[serde(default = "default_per_page")]
|
||||||
|
pub per_page: u32,
|
||||||
|
pub search: Option<String>,
|
||||||
|
pub is_on_loan: Option<bool>,
|
||||||
|
pub is_disposed: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_page() -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_per_page() -> u32 {
|
||||||
|
20
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_items(
|
||||||
|
State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
Query(params): Query<ItemsQuery>,
|
||||||
|
) -> AppResult<Json<ItemsListResponse>> {
|
||||||
|
let response = item_service
|
||||||
|
.list_items(
|
||||||
|
params.page,
|
||||||
|
params.per_page,
|
||||||
|
params.search,
|
||||||
|
params.is_on_loan,
|
||||||
|
params.is_disposed,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_item(
|
||||||
|
State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> AppResult<Json<Item>> {
|
||||||
|
let item = item_service.get_item(id).await?;
|
||||||
|
Ok(Json(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_item_by_label(
|
||||||
|
State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
Path(label_id): Path<String>,
|
||||||
|
) -> AppResult<Json<Item>> {
|
||||||
|
let item = item_service.get_item_by_label(&label_id).await?;
|
||||||
|
Ok(Json(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_item(
|
||||||
|
State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
Json(req): Json<CreateItemRequest>,
|
||||||
|
) -> AppResult<(StatusCode, Json<Item>)> {
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| crate::error::AppError::ValidationError(e.to_string()))?;
|
||||||
|
|
||||||
|
let item = item_service.create_item(req).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(item)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_item(
|
||||||
|
State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(req): Json<UpdateItemRequest>,
|
||||||
|
) -> AppResult<Json<Item>> {
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| crate::error::AppError::ValidationError(e.to_string()))?;
|
||||||
|
|
||||||
|
let item = item_service.update_item(id, req).await?;
|
||||||
|
Ok(Json(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_item(
|
||||||
|
State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> AppResult<StatusCode> {
|
||||||
|
item_service.delete_item(id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn dispose_item(
|
||||||
|
State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> AppResult<Json<Item>> {
|
||||||
|
let item = item_service.dispose_item(id).await?;
|
||||||
|
Ok(Json(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn undispose_item(
|
||||||
|
State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> AppResult<Json<Item>> {
|
||||||
|
let item = item_service.undispose_item(id).await?;
|
||||||
|
Ok(Json(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SuggestionsResponse {
|
||||||
|
pub suggestions: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_connection_names_suggestions(
|
||||||
|
State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
) -> AppResult<Json<SuggestionsResponse>> {
|
||||||
|
let suggestions = item_service.get_connection_names_suggestions().await?;
|
||||||
|
Ok(Json(SuggestionsResponse { suggestions }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_storage_locations_suggestions(
|
||||||
|
State((_cable_color_service, item_service, _loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
) -> AppResult<Json<SuggestionsResponse>> {
|
||||||
|
let suggestions = item_service.get_storage_locations_suggestions().await?;
|
||||||
|
Ok(Json(SuggestionsResponse { suggestions }))
|
||||||
|
}
|
||||||
79
src/handlers/loans.rs
Normal file
79
src/handlers/loans.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use crate::error::AppResult;
|
||||||
|
use crate::models::{CreateLoanRequest, Loan, LoansListResponse, ReturnLoanRequest};
|
||||||
|
use crate::services::{ItemService, LoanService, StorageService, CableColorService};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LoansQuery {
|
||||||
|
#[serde(default = "default_page")]
|
||||||
|
pub page: u32,
|
||||||
|
#[serde(default = "default_per_page")]
|
||||||
|
pub per_page: u32,
|
||||||
|
pub item_id: Option<i64>,
|
||||||
|
pub student_number: Option<String>,
|
||||||
|
pub active_only: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_page() -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_per_page() -> u32 {
|
||||||
|
20
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_loans(
|
||||||
|
State((_cable_color_service, _item_service, loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
Query(params): Query<LoansQuery>,
|
||||||
|
) -> AppResult<Json<LoansListResponse>> {
|
||||||
|
let response = loan_service
|
||||||
|
.list_loans(
|
||||||
|
params.page,
|
||||||
|
params.per_page,
|
||||||
|
params.item_id,
|
||||||
|
params.student_number,
|
||||||
|
params.active_only,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_loan(
|
||||||
|
State((_cable_color_service, _item_service, loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> AppResult<Json<Loan>> {
|
||||||
|
let loan = loan_service.get_loan(id).await?;
|
||||||
|
Ok(Json(loan))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_loan(
|
||||||
|
State((_cable_color_service, _item_service, loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
Json(req): Json<CreateLoanRequest>,
|
||||||
|
) -> AppResult<(StatusCode, Json<Loan>)> {
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| crate::error::AppError::ValidationError(e.to_string()))?;
|
||||||
|
|
||||||
|
let loan = loan_service.create_loan(req).await?;
|
||||||
|
Ok((StatusCode::CREATED, Json(loan)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn return_loan(
|
||||||
|
State((_cable_color_service, _item_service, loan_service, _storage_service)): State<(Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>)>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(req): Json<ReturnLoanRequest>,
|
||||||
|
) -> AppResult<Json<Loan>> {
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| crate::error::AppError::ValidationError(e.to_string()))?;
|
||||||
|
|
||||||
|
let loan = loan_service.return_loan(id, req).await?;
|
||||||
|
Ok(Json(loan))
|
||||||
|
}
|
||||||
15
src/handlers/mod.rs
Normal file
15
src/handlers/mod.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
pub mod items;
|
||||||
|
pub mod loans;
|
||||||
|
pub mod images;
|
||||||
|
pub mod cable_colors;
|
||||||
|
|
||||||
|
pub use items::*;
|
||||||
|
pub use loans::*;
|
||||||
|
pub use images::*;
|
||||||
|
pub use cable_colors::*;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::services::{ItemService, LoanService, StorageService, CableColorService};
|
||||||
|
|
||||||
|
pub type AppState = (Arc<CableColorService>, Arc<ItemService>, Arc<LoanService>, Arc<StorageService>, Arc<Config>);
|
||||||
111
src/main.rs
Normal file
111
src/main.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
use axum::{
|
||||||
|
routing::{delete, get, post, put},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tower_http::cors::CorsLayer;
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
use tracing::info;
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod db;
|
||||||
|
mod error;
|
||||||
|
mod handlers;
|
||||||
|
mod models;
|
||||||
|
mod services;
|
||||||
|
|
||||||
|
use crate::config::{Config, StorageType};
|
||||||
|
use crate::db::DatabasePool;
|
||||||
|
use crate::services::{ItemService, LoanService, StorageService, CableColorService};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
// Initialize tracing
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "hyperdashi_server=debug,tower_http=debug".into()),
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
info!("Starting HyperDashi server...");
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
let config = Config::from_env()?;
|
||||||
|
info!("Configuration loaded: {:?}", config);
|
||||||
|
|
||||||
|
// Initialize database connection
|
||||||
|
let db_pool = DatabasePool::new(&config).await?;
|
||||||
|
info!("Database connection established");
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
db_pool.migrate().await?;
|
||||||
|
info!("Database migrations completed");
|
||||||
|
|
||||||
|
// Initialize storage
|
||||||
|
let storage = Arc::new(StorageService::new(&config).await?);
|
||||||
|
info!("Storage initialized");
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
let cable_color_service = Arc::new(CableColorService::new(db_pool.clone()));
|
||||||
|
let item_service = Arc::new(ItemService::new(db_pool.clone()));
|
||||||
|
let loan_service = Arc::new(LoanService::new(db_pool.clone()));
|
||||||
|
|
||||||
|
// Build application routes
|
||||||
|
let mut app = Router::new()
|
||||||
|
.route("/", get(root))
|
||||||
|
.route("/api/v1/health", get(health_check))
|
||||||
|
// Item routes
|
||||||
|
.route("/api/v1/items", get(handlers::list_items).post(handlers::create_item))
|
||||||
|
.route("/api/v1/items/:id", get(handlers::get_item).put(handlers::update_item).delete(handlers::delete_item))
|
||||||
|
.route("/api/v1/items/:id/dispose", post(handlers::dispose_item))
|
||||||
|
.route("/api/v1/items/:id/undispose", post(handlers::undispose_item))
|
||||||
|
.route("/api/v1/items/by-label/:label_id", get(handlers::get_item_by_label))
|
||||||
|
.route("/api/v1/items/suggestions/connection_names", get(handlers::get_connection_names_suggestions))
|
||||||
|
.route("/api/v1/items/suggestions/storage_locations", get(handlers::get_storage_locations_suggestions))
|
||||||
|
// Cable color routes
|
||||||
|
.route("/api/v1/cable_colors", get(handlers::list_cable_colors).post(handlers::create_cable_color))
|
||||||
|
.route("/api/v1/cable_colors/:id", get(handlers::get_cable_color).put(handlers::update_cable_color).delete(handlers::delete_cable_color))
|
||||||
|
// Loan routes
|
||||||
|
.route("/api/v1/loans", get(handlers::list_loans).post(handlers::create_loan))
|
||||||
|
.route("/api/v1/loans/:id", get(handlers::get_loan))
|
||||||
|
.route("/api/v1/loans/:id/return", post(handlers::return_loan))
|
||||||
|
// Image routes
|
||||||
|
.route("/api/v1/images/upload", post(handlers::upload_image))
|
||||||
|
// Add state - combine services
|
||||||
|
.with_state((cable_color_service, item_service, loan_service, storage))
|
||||||
|
.layer(CorsLayer::permissive())
|
||||||
|
.layer(TraceLayer::new_for_http());
|
||||||
|
|
||||||
|
// Add static file serving for local storage
|
||||||
|
if matches!(config.storage.storage_type, StorageType::Local) {
|
||||||
|
if let Some(local_config) = &config.storage.local {
|
||||||
|
info!("Enabling static file serving for uploads at {}", local_config.path);
|
||||||
|
app = app.nest_service("/uploads", ServeDir::new(&local_config.path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
let addr = SocketAddr::from((
|
||||||
|
config.server.host.parse::<std::net::IpAddr>()?,
|
||||||
|
config.server.port,
|
||||||
|
));
|
||||||
|
info!("Server listening on {}", addr);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn root() -> &'static str {
|
||||||
|
"HyperDashi Server"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health_check() -> &'static str {
|
||||||
|
"OK"
|
||||||
|
}
|
||||||
46
src/models/cable_color.rs
Normal file
46
src/models/cable_color.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CableColor {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub hex_code: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate)]
|
||||||
|
pub struct CreateCableColorRequest {
|
||||||
|
#[validate(length(min = 1, max = 100))]
|
||||||
|
pub name: String,
|
||||||
|
#[validate(regex(path = "*crate::models::cable_color::HEX_COLOR_REGEX"))]
|
||||||
|
pub hex_code: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate)]
|
||||||
|
pub struct UpdateCableColorRequest {
|
||||||
|
#[validate(length(min = 1, max = 100))]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[validate(regex(path = "*crate::models::cable_color::HEX_COLOR_REGEX"))]
|
||||||
|
pub hex_code: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct CableColorsListResponse {
|
||||||
|
pub cable_colors: Vec<CableColor>,
|
||||||
|
pub total: i64,
|
||||||
|
pub page: u32,
|
||||||
|
pub per_page: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
pub static ref HEX_COLOR_REGEX: Regex = Regex::new(r"^#[0-9A-Fa-f]{6}$").unwrap();
|
||||||
|
}
|
||||||
107
src/models/item.rs
Normal file
107
src/models/item.rs
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Item {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub label_id: String,
|
||||||
|
pub model_number: Option<String>,
|
||||||
|
pub remarks: Option<String>,
|
||||||
|
pub purchase_year: Option<i64>,
|
||||||
|
pub purchase_amount: Option<f64>,
|
||||||
|
pub durability_years: Option<i64>,
|
||||||
|
pub is_depreciation_target: Option<bool>,
|
||||||
|
pub connection_names: Option<Vec<String>>,
|
||||||
|
pub cable_color_pattern: Option<Vec<String>>,
|
||||||
|
pub storage_locations: Option<Vec<String>>,
|
||||||
|
pub is_on_loan: Option<bool>,
|
||||||
|
pub qr_code_type: Option<String>,
|
||||||
|
pub is_disposed: Option<bool>,
|
||||||
|
pub image_url: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
|
||||||
|
pub struct CreateItemRequest {
|
||||||
|
#[validate(length(min = 1, max = 255))]
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
#[validate(length(min = 1, max = 50))]
|
||||||
|
pub label_id: String,
|
||||||
|
|
||||||
|
#[validate(length(max = 255))]
|
||||||
|
pub model_number: Option<String>,
|
||||||
|
|
||||||
|
pub remarks: Option<String>,
|
||||||
|
|
||||||
|
#[validate(range(min = 1900, max = 2100))]
|
||||||
|
pub purchase_year: Option<i64>,
|
||||||
|
|
||||||
|
pub purchase_amount: Option<f64>,
|
||||||
|
|
||||||
|
#[validate(range(min = 1, max = 100))]
|
||||||
|
pub durability_years: Option<i64>,
|
||||||
|
|
||||||
|
pub is_depreciation_target: Option<bool>,
|
||||||
|
|
||||||
|
pub connection_names: Option<Vec<String>>,
|
||||||
|
|
||||||
|
pub cable_color_pattern: Option<Vec<String>>,
|
||||||
|
|
||||||
|
pub storage_locations: Option<Vec<String>>,
|
||||||
|
|
||||||
|
pub qr_code_type: Option<String>,
|
||||||
|
|
||||||
|
#[validate(url)]
|
||||||
|
pub image_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
|
||||||
|
pub struct UpdateItemRequest {
|
||||||
|
#[validate(length(min = 1, max = 255))]
|
||||||
|
pub name: Option<String>,
|
||||||
|
|
||||||
|
#[validate(length(min = 1, max = 50))]
|
||||||
|
pub label_id: Option<String>,
|
||||||
|
|
||||||
|
#[validate(length(max = 255))]
|
||||||
|
pub model_number: Option<String>,
|
||||||
|
|
||||||
|
pub remarks: Option<String>,
|
||||||
|
|
||||||
|
#[validate(range(min = 1900, max = 2100))]
|
||||||
|
pub purchase_year: Option<i64>,
|
||||||
|
|
||||||
|
pub purchase_amount: Option<f64>,
|
||||||
|
|
||||||
|
#[validate(range(min = 1, max = 100))]
|
||||||
|
pub durability_years: Option<i64>,
|
||||||
|
|
||||||
|
pub is_depreciation_target: Option<bool>,
|
||||||
|
|
||||||
|
pub connection_names: Option<Vec<String>>,
|
||||||
|
|
||||||
|
pub cable_color_pattern: Option<Vec<String>>,
|
||||||
|
|
||||||
|
pub storage_locations: Option<Vec<String>>,
|
||||||
|
|
||||||
|
pub is_on_loan: Option<bool>,
|
||||||
|
|
||||||
|
pub qr_code_type: Option<String>,
|
||||||
|
|
||||||
|
pub is_disposed: Option<bool>,
|
||||||
|
|
||||||
|
#[validate(url)]
|
||||||
|
pub image_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ItemsListResponse {
|
||||||
|
pub items: Vec<Item>,
|
||||||
|
pub total: i64,
|
||||||
|
pub page: u32,
|
||||||
|
pub per_page: u32,
|
||||||
|
}
|
||||||
72
src/models/loan.rs
Normal file
72
src/models/loan.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Loan {
|
||||||
|
pub id: i64,
|
||||||
|
pub item_id: i64,
|
||||||
|
pub student_number: String,
|
||||||
|
pub student_name: String,
|
||||||
|
pub organization: Option<String>,
|
||||||
|
pub loan_date: DateTime<Utc>,
|
||||||
|
pub return_date: Option<DateTime<Utc>>,
|
||||||
|
pub remarks: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
|
||||||
|
pub struct CreateLoanRequest {
|
||||||
|
pub item_id: i64,
|
||||||
|
|
||||||
|
#[validate(length(min = 1, max = 20))]
|
||||||
|
pub student_number: String,
|
||||||
|
|
||||||
|
#[validate(length(min = 1, max = 100))]
|
||||||
|
pub student_name: String,
|
||||||
|
|
||||||
|
#[validate(length(max = 255))]
|
||||||
|
pub organization: Option<String>,
|
||||||
|
|
||||||
|
pub remarks: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
|
||||||
|
pub struct ReturnLoanRequest {
|
||||||
|
pub return_date: Option<DateTime<Utc>>,
|
||||||
|
pub remarks: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LoanWithItem {
|
||||||
|
pub id: i64,
|
||||||
|
pub item_id: i64,
|
||||||
|
pub item_name: String,
|
||||||
|
pub item_label_id: String,
|
||||||
|
pub student_number: String,
|
||||||
|
pub student_name: String,
|
||||||
|
pub organization: Option<String>,
|
||||||
|
pub loan_date: DateTime<Utc>,
|
||||||
|
pub return_date: Option<DateTime<Utc>>,
|
||||||
|
pub remarks: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LoansListResponse {
|
||||||
|
pub loans: Vec<LoanWithItem>,
|
||||||
|
pub total: i64,
|
||||||
|
pub page: u32,
|
||||||
|
pub per_page: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LoanFilters {
|
||||||
|
pub item_id: Option<i64>,
|
||||||
|
pub student_number: Option<String>,
|
||||||
|
pub active_only: Option<bool>,
|
||||||
|
pub page: Option<u32>,
|
||||||
|
pub per_page: Option<u32>,
|
||||||
|
}
|
||||||
7
src/models/mod.rs
Normal file
7
src/models/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
pub mod item;
|
||||||
|
pub mod loan;
|
||||||
|
pub mod cable_color;
|
||||||
|
|
||||||
|
pub use item::*;
|
||||||
|
pub use loan::*;
|
||||||
|
pub use cable_color::*;
|
||||||
170
src/services/cable_color_service.rs
Normal file
170
src/services/cable_color_service.rs
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
use crate::db::DatabasePool;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::models::{CableColor, CableColorsListResponse, CreateCableColorRequest, UpdateCableColorRequest};
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
pub struct CableColorService {
|
||||||
|
db: DatabasePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CableColorService {
|
||||||
|
pub fn new(db: DatabasePool) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_cable_color(&self, req: CreateCableColorRequest) -> AppResult<CableColor> {
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO cable_colors (name, hex_code, description)
|
||||||
|
VALUES (?1, ?2, ?3)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&req.name)
|
||||||
|
.bind(&req.hex_code)
|
||||||
|
.bind(&req.description)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let id = result.last_insert_rowid();
|
||||||
|
self.get_cable_color(id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_cable_color(&self, id: i64) -> AppResult<CableColor> {
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT id, name, hex_code, description, created_at, updated_at
|
||||||
|
FROM cable_colors
|
||||||
|
WHERE id = ?1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Cable color with id {} not found", id)))?;
|
||||||
|
|
||||||
|
Ok(self.row_to_cable_color(row))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_cable_colors(
|
||||||
|
&self,
|
||||||
|
page: u32,
|
||||||
|
per_page: u32,
|
||||||
|
) -> AppResult<CableColorsListResponse> {
|
||||||
|
let offset = ((page - 1) * per_page) as i64;
|
||||||
|
let limit = per_page as i64;
|
||||||
|
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT id, name, hex_code, description, created_at, updated_at
|
||||||
|
FROM cable_colors
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?1 OFFSET ?2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let cable_colors: Vec<CableColor> = rows.into_iter()
|
||||||
|
.map(|row| self.row_to_cable_color(row))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let count_row = sqlx::query("SELECT COUNT(*) as count FROM cable_colors")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
let total: i64 = count_row.get("count");
|
||||||
|
|
||||||
|
Ok(CableColorsListResponse {
|
||||||
|
cable_colors,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_cable_color(&self, id: i64, req: UpdateCableColorRequest) -> AppResult<CableColor> {
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
// まず色が存在するかチェック
|
||||||
|
let _existing_color = self.get_cable_color(id).await?;
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE cable_colors SET
|
||||||
|
name = COALESCE(?2, name),
|
||||||
|
hex_code = COALESCE(?3, hex_code),
|
||||||
|
description = COALESCE(?4, description),
|
||||||
|
updated_at = ?5
|
||||||
|
WHERE id = ?1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(&req.name)
|
||||||
|
.bind(&req.hex_code)
|
||||||
|
.bind(&req.description)
|
||||||
|
.bind(now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
self.get_cable_color(id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_cable_color(&self, id: i64) -> AppResult<()> {
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
let result = sqlx::query("DELETE FROM cable_colors WHERE id = ?1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(AppError::NotFound(format!("Cable color with id {} not found", id)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_cable_color(&self, row: sqlx::sqlite::SqliteRow) -> CableColor {
|
||||||
|
CableColor {
|
||||||
|
id: row.get("id"),
|
||||||
|
name: row.get("name"),
|
||||||
|
hex_code: row.get("hex_code"),
|
||||||
|
description: row.get("description"),
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
updated_at: row.get("updated_at"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
496
src/services/item_service.rs
Normal file
496
src/services/item_service.rs
Normal file
|
|
@ -0,0 +1,496 @@
|
||||||
|
use crate::db::DatabasePool;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::models::{CreateItemRequest, Item, ItemsListResponse, UpdateItemRequest};
|
||||||
|
use chrono::Utc;
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
pub struct ItemService {
|
||||||
|
db: DatabasePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ItemService {
|
||||||
|
pub fn new(db: DatabasePool) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_item(&self, req: CreateItemRequest) -> AppResult<Item> {
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
let connection_names = req.connection_names
|
||||||
|
.map(|v| serde_json::to_string(&v).unwrap_or_default());
|
||||||
|
let cable_color_pattern = req.cable_color_pattern
|
||||||
|
.map(|v| serde_json::to_string(&v).unwrap_or_default());
|
||||||
|
let storage_locations = req.storage_locations
|
||||||
|
.map(|v| serde_json::to_string(&v).unwrap_or_default());
|
||||||
|
let is_depreciation_target = req.is_depreciation_target.unwrap_or(false);
|
||||||
|
|
||||||
|
let result = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO items (
|
||||||
|
name, label_id, model_number, remarks, purchase_year,
|
||||||
|
purchase_amount, durability_years, is_depreciation_target, connection_names,
|
||||||
|
cable_color_pattern, storage_locations, qr_code_type, image_url
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
|
||||||
|
"#,
|
||||||
|
req.name,
|
||||||
|
req.label_id,
|
||||||
|
req.model_number,
|
||||||
|
req.remarks,
|
||||||
|
req.purchase_year,
|
||||||
|
req.purchase_amount,
|
||||||
|
req.durability_years,
|
||||||
|
is_depreciation_target,
|
||||||
|
connection_names,
|
||||||
|
cable_color_pattern,
|
||||||
|
storage_locations,
|
||||||
|
req.qr_code_type,
|
||||||
|
req.image_url
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let id = result.last_insert_rowid();
|
||||||
|
self.get_item(id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_item(&self, id: i64) -> AppResult<Item> {
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id, name, label_id, model_number, remarks, purchase_year,
|
||||||
|
purchase_amount, durability_years, is_depreciation_target,
|
||||||
|
connection_names, cable_color_pattern, storage_locations,
|
||||||
|
is_on_loan, qr_code_type, is_disposed, image_url,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM items
|
||||||
|
WHERE id = ?1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Item with id {} not found", id)))?;
|
||||||
|
|
||||||
|
Ok(self.row_to_item(row))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_item_by_label(&self, label_id: &str) -> AppResult<Item> {
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id, name, label_id, model_number, remarks, purchase_year,
|
||||||
|
purchase_amount, durability_years, is_depreciation_target,
|
||||||
|
connection_names, cable_color_pattern, storage_locations,
|
||||||
|
is_on_loan, qr_code_type, is_disposed, image_url,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM items
|
||||||
|
WHERE label_id = ?1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(label_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Item with label_id {} not found", label_id)))?;
|
||||||
|
|
||||||
|
Ok(self.row_to_item(row))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_items(
|
||||||
|
&self,
|
||||||
|
page: u32,
|
||||||
|
per_page: u32,
|
||||||
|
search: Option<String>,
|
||||||
|
is_on_loan: Option<bool>,
|
||||||
|
is_disposed: Option<bool>,
|
||||||
|
) -> AppResult<ItemsListResponse> {
|
||||||
|
let offset = ((page - 1) * per_page) as i64;
|
||||||
|
let limit = per_page as i64;
|
||||||
|
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
// 動的WHEREクエリを構築(簡単な方法)
|
||||||
|
let mut where_conditions = Vec::new();
|
||||||
|
|
||||||
|
// 検索条件
|
||||||
|
if search.is_some() {
|
||||||
|
where_conditions.push("(name LIKE ? OR label_id LIKE ? OR model_number LIKE ? OR remarks LIKE ?)".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 貸出状態フィルター
|
||||||
|
if is_on_loan.is_some() {
|
||||||
|
where_conditions.push("is_on_loan = ?".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 廃棄状態フィルター
|
||||||
|
if is_disposed.is_some() {
|
||||||
|
where_conditions.push("is_disposed = ?".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let where_clause = if where_conditions.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("WHERE {}", where_conditions.join(" AND "))
|
||||||
|
};
|
||||||
|
|
||||||
|
// シンプルなアプローチで実装(フィルター条件ごとに分岐)
|
||||||
|
let (items, total) = if search.is_none() && is_on_loan.is_none() && is_disposed.is_none() {
|
||||||
|
// フィルターなし
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id, name, label_id, model_number, remarks, purchase_year,
|
||||||
|
purchase_amount, durability_years, is_depreciation_target,
|
||||||
|
connection_names, cable_color_pattern, storage_locations,
|
||||||
|
is_on_loan, qr_code_type, is_disposed, image_url,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM items
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?1 OFFSET ?2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let items: Vec<Item> = rows.into_iter()
|
||||||
|
.map(|row| self.row_to_item(row))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let count_row = sqlx::query("SELECT COUNT(*) as count FROM items")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
let total: i64 = count_row.get("count");
|
||||||
|
|
||||||
|
(items, total)
|
||||||
|
} else {
|
||||||
|
// フィルターあり - 動的クエリを使用
|
||||||
|
let query_str = format!(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id, name, label_id, model_number, remarks, purchase_year,
|
||||||
|
purchase_amount, durability_years, is_depreciation_target,
|
||||||
|
connection_names, cable_color_pattern, storage_locations,
|
||||||
|
is_on_loan, qr_code_type, is_disposed, image_url,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM items
|
||||||
|
{}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"#,
|
||||||
|
where_clause
|
||||||
|
);
|
||||||
|
|
||||||
|
let count_query_str = format!("SELECT COUNT(*) as count FROM items {}", where_clause);
|
||||||
|
|
||||||
|
// パラメーターをバインドするためのヘルパー関数
|
||||||
|
let mut query = sqlx::query(&query_str);
|
||||||
|
let mut count_query = sqlx::query(&count_query_str);
|
||||||
|
|
||||||
|
// 検索条件
|
||||||
|
if let Some(search_term) = &search {
|
||||||
|
let search_pattern = format!("%{}%", search_term);
|
||||||
|
query = query.bind(search_pattern.clone()).bind(search_pattern.clone()).bind(search_pattern.clone()).bind(search_pattern.clone());
|
||||||
|
count_query = count_query.bind(search_pattern.clone()).bind(search_pattern.clone()).bind(search_pattern.clone()).bind(search_pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 貸出状態フィルター
|
||||||
|
if let Some(loan_status) = is_on_loan {
|
||||||
|
let loan_value = if loan_status { 1i32 } else { 0i32 };
|
||||||
|
query = query.bind(loan_value);
|
||||||
|
count_query = count_query.bind(loan_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 廃棄状態フィルター
|
||||||
|
if let Some(disposed_status) = is_disposed {
|
||||||
|
let disposed_value = if disposed_status { 1i32 } else { 0i32 };
|
||||||
|
query = query.bind(disposed_value);
|
||||||
|
count_query = count_query.bind(disposed_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LIMIT/OFFSETをバインド
|
||||||
|
query = query.bind(limit).bind(offset);
|
||||||
|
|
||||||
|
let rows = query.fetch_all(pool).await?;
|
||||||
|
let items: Vec<Item> = rows.into_iter()
|
||||||
|
.map(|row| self.row_to_item(row))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let count_row = count_query.fetch_one(pool).await?;
|
||||||
|
let total: i64 = count_row.get("count");
|
||||||
|
|
||||||
|
(items, total)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ItemsListResponse {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_item(&self, id: i64, req: UpdateItemRequest) -> AppResult<Item> {
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
// まず物品が存在するかチェック
|
||||||
|
let _existing_item = self.get_item(id).await?;
|
||||||
|
|
||||||
|
// JSON配列フィールドをシリアライズ
|
||||||
|
let connection_names_json = req.connection_names
|
||||||
|
.as_ref()
|
||||||
|
.map(|names| serde_json::to_string(names))
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| AppError::InternalServerError(format!("Failed to serialize connection_names: {}", e)))?;
|
||||||
|
|
||||||
|
let cable_color_pattern_json = req.cable_color_pattern
|
||||||
|
.as_ref()
|
||||||
|
.map(|pattern| serde_json::to_string(pattern))
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| AppError::InternalServerError(format!("Failed to serialize cable_color_pattern: {}", e)))?;
|
||||||
|
|
||||||
|
let storage_locations_json = req.storage_locations
|
||||||
|
.as_ref()
|
||||||
|
.map(|locations| serde_json::to_string(locations))
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| AppError::InternalServerError(format!("Failed to serialize storage_locations: {}", e)))?;
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
UPDATE items SET
|
||||||
|
name = COALESCE(?2, name),
|
||||||
|
label_id = COALESCE(?3, label_id),
|
||||||
|
model_number = COALESCE(?4, model_number),
|
||||||
|
remarks = COALESCE(?5, remarks),
|
||||||
|
purchase_year = COALESCE(?6, purchase_year),
|
||||||
|
purchase_amount = COALESCE(?7, purchase_amount),
|
||||||
|
durability_years = COALESCE(?8, durability_years),
|
||||||
|
is_depreciation_target = COALESCE(?9, is_depreciation_target),
|
||||||
|
connection_names = COALESCE(?10, connection_names),
|
||||||
|
cable_color_pattern = COALESCE(?11, cable_color_pattern),
|
||||||
|
storage_locations = COALESCE(?12, storage_locations),
|
||||||
|
qr_code_type = COALESCE(?13, qr_code_type),
|
||||||
|
image_url = COALESCE(?14, image_url),
|
||||||
|
updated_at = ?15
|
||||||
|
WHERE id = ?1
|
||||||
|
"#,
|
||||||
|
id,
|
||||||
|
req.name,
|
||||||
|
req.label_id,
|
||||||
|
req.model_number,
|
||||||
|
req.remarks,
|
||||||
|
req.purchase_year,
|
||||||
|
req.purchase_amount,
|
||||||
|
req.durability_years,
|
||||||
|
req.is_depreciation_target,
|
||||||
|
connection_names_json,
|
||||||
|
cable_color_pattern_json,
|
||||||
|
storage_locations_json,
|
||||||
|
req.qr_code_type,
|
||||||
|
req.image_url,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 更新後の物品を取得して返す
|
||||||
|
self.get_item(id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_item(&self, id: i64) -> AppResult<()> {
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
// まず物品が存在し、貸出中でないかチェック
|
||||||
|
let item = self.get_item(id).await?;
|
||||||
|
|
||||||
|
if item.is_on_loan.unwrap_or(false) {
|
||||||
|
return Err(AppError::BadRequest("Cannot delete item that is currently on loan".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// アクティブな貸出がないかチェック
|
||||||
|
let active_loans = sqlx::query!(
|
||||||
|
"SELECT COUNT(*) as count FROM loans WHERE item_id = ?1 AND return_date IS NULL",
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if active_loans.count > 0 {
|
||||||
|
return Err(AppError::BadRequest("Cannot delete item with active loans".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = sqlx::query("DELETE FROM items WHERE id = ?1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(AppError::NotFound(format!("Item with id {} not found", id)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn dispose_item(&self, id: i64) -> AppResult<Item> {
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
let now = Utc::now();
|
||||||
|
let result = sqlx::query("UPDATE items SET is_disposed = 1, updated_at = ?2 WHERE id = ?1")
|
||||||
|
.bind(id)
|
||||||
|
.bind(now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(AppError::NotFound(format!("Item with id {} not found", id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.get_item(id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn undispose_item(&self, id: i64) -> AppResult<Item> {
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
let now = Utc::now();
|
||||||
|
let result = sqlx::query("UPDATE items SET is_disposed = 0, updated_at = ?2 WHERE id = ?1")
|
||||||
|
.bind(id)
|
||||||
|
.bind(now)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(AppError::NotFound(format!("Item with id {} not found", id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.get_item(id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_connection_names_suggestions(&self) -> AppResult<Vec<String>> {
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
let rows = sqlx::query("SELECT DISTINCT connection_names FROM items WHERE connection_names IS NOT NULL AND connection_names != ''")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut suggestions = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
if let Some(json_str) = row.get::<Option<String>, _>("connection_names") {
|
||||||
|
if let Ok(names) = serde_json::from_str::<Vec<String>>(&json_str) {
|
||||||
|
suggestions.extend(names);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重複を除去してソート
|
||||||
|
suggestions.sort();
|
||||||
|
suggestions.dedup();
|
||||||
|
Ok(suggestions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_storage_locations_suggestions(&self) -> AppResult<Vec<String>> {
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
let rows = sqlx::query("SELECT DISTINCT storage_locations FROM items WHERE storage_locations IS NOT NULL AND storage_locations != ''")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut suggestions = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
if let Some(json_str) = row.get::<Option<String>, _>("storage_locations") {
|
||||||
|
if let Ok(locations) = serde_json::from_str::<Vec<String>>(&json_str) {
|
||||||
|
suggestions.extend(locations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重複を除去してソート
|
||||||
|
suggestions.sort();
|
||||||
|
suggestions.dedup();
|
||||||
|
Ok(suggestions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_item(&self, row: sqlx::sqlite::SqliteRow) -> Item {
|
||||||
|
let connection_names: Option<Vec<String>> = row.get::<Option<String>, _>("connection_names")
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok());
|
||||||
|
let cable_color_pattern: Option<Vec<String>> = row.get::<Option<String>, _>("cable_color_pattern")
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok());
|
||||||
|
let storage_locations: Option<Vec<String>> = row.get::<Option<String>, _>("storage_locations")
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok());
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: row.get("id"),
|
||||||
|
name: row.get("name"),
|
||||||
|
label_id: row.get("label_id"),
|
||||||
|
model_number: row.get("model_number"),
|
||||||
|
remarks: row.get("remarks"),
|
||||||
|
purchase_year: row.get("purchase_year"),
|
||||||
|
purchase_amount: row.get("purchase_amount"),
|
||||||
|
durability_years: row.get("durability_years"),
|
||||||
|
is_depreciation_target: row.get("is_depreciation_target"),
|
||||||
|
connection_names,
|
||||||
|
cable_color_pattern,
|
||||||
|
storage_locations,
|
||||||
|
is_on_loan: row.get("is_on_loan"),
|
||||||
|
qr_code_type: row.get("qr_code_type"),
|
||||||
|
is_disposed: row.get("is_disposed"),
|
||||||
|
image_url: row.get("image_url"),
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
updated_at: row.get("updated_at"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
313
src/services/loan_service.rs
Normal file
313
src/services/loan_service.rs
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
use crate::db::DatabasePool;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::models::{CreateLoanRequest, Loan, LoanWithItem, LoansListResponse, ReturnLoanRequest};
|
||||||
|
use chrono::Utc;
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
pub struct LoanService {
|
||||||
|
db: DatabasePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoanService {
|
||||||
|
pub fn new(db: DatabasePool) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_loan(&self, req: CreateLoanRequest) -> AppResult<Loan> {
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
// まず、物品が存在し、貸出可能かチェック
|
||||||
|
let item_check = sqlx::query!(
|
||||||
|
"SELECT id, name, is_on_loan, is_disposed FROM items WHERE id = ?1",
|
||||||
|
req.item_id
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let item = item_check.ok_or_else(||
|
||||||
|
AppError::NotFound(format!("Item with id {} not found", req.item_id))
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if item.is_on_loan.unwrap_or(false) {
|
||||||
|
return Err(AppError::BadRequest("Item is already on loan".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.is_disposed.unwrap_or(false) {
|
||||||
|
return Err(AppError::BadRequest("Item is disposed and cannot be loaned".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 貸出記録を作成
|
||||||
|
let result = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO loans (
|
||||||
|
item_id, student_number, student_name, organization, remarks
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
"#,
|
||||||
|
req.item_id,
|
||||||
|
req.student_number,
|
||||||
|
req.student_name,
|
||||||
|
req.organization,
|
||||||
|
req.remarks
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 物品の貸出状態を更新
|
||||||
|
let now = Utc::now();
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE items SET is_on_loan = 1, updated_at = ?2 WHERE id = ?1",
|
||||||
|
req.item_id,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let loan_id = result.last_insert_rowid();
|
||||||
|
self.get_loan(loan_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_loan(&self, id: i64) -> AppResult<Loan> {
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id, item_id, student_number, student_name, organization,
|
||||||
|
loan_date, return_date, remarks, created_at, updated_at
|
||||||
|
FROM loans
|
||||||
|
WHERE id = ?1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Loan with id {} not found", id)))?;
|
||||||
|
|
||||||
|
Ok(self.row_to_loan(row))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_loans(
|
||||||
|
&self,
|
||||||
|
page: u32,
|
||||||
|
per_page: u32,
|
||||||
|
item_id: Option<i64>,
|
||||||
|
student_number: Option<String>,
|
||||||
|
active_only: Option<bool>,
|
||||||
|
) -> AppResult<LoansListResponse> {
|
||||||
|
let offset = ((page - 1) * per_page) as i64;
|
||||||
|
let limit = per_page as i64;
|
||||||
|
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
// フィルタリング機能を実装
|
||||||
|
let (loans, total) = if item_id.is_none() && student_number.is_none() && active_only.is_none() {
|
||||||
|
// フィルターなし
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
l.id, l.item_id, l.student_number, l.student_name, l.organization,
|
||||||
|
l.loan_date, l.return_date, l.remarks, l.created_at, l.updated_at,
|
||||||
|
i.name as item_name, i.label_id as item_label_id
|
||||||
|
FROM loans l
|
||||||
|
INNER JOIN items i ON l.item_id = i.id
|
||||||
|
ORDER BY l.created_at DESC
|
||||||
|
LIMIT ?1 OFFSET ?2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let loans: Vec<LoanWithItem> = rows.into_iter()
|
||||||
|
.map(|row| self.row_to_loan_with_item(row))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let count_row = sqlx::query("SELECT COUNT(*) as count FROM loans")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
let total: i64 = count_row.get("count");
|
||||||
|
|
||||||
|
(loans, total)
|
||||||
|
} else {
|
||||||
|
// フィルターあり - 動的クエリを構築
|
||||||
|
let mut where_conditions = Vec::new();
|
||||||
|
|
||||||
|
// 物品IDフィルター
|
||||||
|
if item_id.is_some() {
|
||||||
|
where_conditions.push("l.item_id = ?".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 学籍番号フィルター
|
||||||
|
if student_number.is_some() {
|
||||||
|
where_conditions.push("l.student_number = ?".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// アクティブ貸出のみフィルター
|
||||||
|
if let Some(true) = active_only {
|
||||||
|
where_conditions.push("l.return_date IS NULL".to_string());
|
||||||
|
} else if let Some(false) = active_only {
|
||||||
|
where_conditions.push("l.return_date IS NOT NULL".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let where_clause = if where_conditions.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("WHERE {}", where_conditions.join(" AND "))
|
||||||
|
};
|
||||||
|
|
||||||
|
let query_str = format!(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
l.id, l.item_id, l.student_number, l.student_name, l.organization,
|
||||||
|
l.loan_date, l.return_date, l.remarks, l.created_at, l.updated_at,
|
||||||
|
i.name as item_name, i.label_id as item_label_id
|
||||||
|
FROM loans l
|
||||||
|
INNER JOIN items i ON l.item_id = i.id
|
||||||
|
{}
|
||||||
|
ORDER BY l.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"#,
|
||||||
|
where_clause
|
||||||
|
);
|
||||||
|
|
||||||
|
let count_query_str = format!(
|
||||||
|
"SELECT COUNT(*) as count FROM loans l INNER JOIN items i ON l.item_id = i.id {}",
|
||||||
|
where_clause
|
||||||
|
);
|
||||||
|
|
||||||
|
// パラメーターをバインド
|
||||||
|
let mut query = sqlx::query(&query_str);
|
||||||
|
let mut count_query = sqlx::query(&count_query_str);
|
||||||
|
|
||||||
|
// 物品IDフィルター
|
||||||
|
if let Some(id) = item_id {
|
||||||
|
query = query.bind(id);
|
||||||
|
count_query = count_query.bind(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 学籍番号フィルター
|
||||||
|
if let Some(ref number) = student_number {
|
||||||
|
query = query.bind(number);
|
||||||
|
count_query = count_query.bind(number);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LIMIT/OFFSETをバインド(active_onlyは既にWHERE句に含まれている)
|
||||||
|
query = query.bind(limit).bind(offset);
|
||||||
|
|
||||||
|
let rows = query.fetch_all(pool).await?;
|
||||||
|
let loans: Vec<LoanWithItem> = rows.into_iter()
|
||||||
|
.map(|row| self.row_to_loan_with_item(row))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let count_row = count_query.fetch_one(pool).await?;
|
||||||
|
let total: i64 = count_row.get("count");
|
||||||
|
|
||||||
|
(loans, total)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(LoansListResponse {
|
||||||
|
loans,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn return_loan(&self, id: i64, req: ReturnLoanRequest) -> AppResult<Loan> {
|
||||||
|
match &self.db {
|
||||||
|
DatabasePool::Postgres(_pool) => {
|
||||||
|
Err(AppError::InternalServerError("PostgreSQL support not implemented yet".to_string()))
|
||||||
|
}
|
||||||
|
DatabasePool::Sqlite(pool) => {
|
||||||
|
// 貸出記録が存在し、未返却かチェック
|
||||||
|
let loan_check = sqlx::query!(
|
||||||
|
"SELECT id, item_id, return_date FROM loans WHERE id = ?1",
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let loan = loan_check.ok_or_else(||
|
||||||
|
AppError::NotFound(format!("Loan with id {} not found", id))
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if loan.return_date.is_some() {
|
||||||
|
return Err(AppError::BadRequest("Loan has already been returned".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let return_date = req.return_date.unwrap_or_else(|| Utc::now());
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
// 貸出記録を更新
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE loans SET return_date = ?2, remarks = ?3, updated_at = ?4 WHERE id = ?1",
|
||||||
|
id,
|
||||||
|
return_date,
|
||||||
|
req.remarks,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 物品の貸出状態を更新
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE items SET is_on_loan = 0, updated_at = ?2 WHERE id = ?1",
|
||||||
|
loan.item_id,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
self.get_loan(id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_loan(&self, row: sqlx::sqlite::SqliteRow) -> Loan {
|
||||||
|
Loan {
|
||||||
|
id: row.get("id"),
|
||||||
|
item_id: row.get("item_id"),
|
||||||
|
student_number: row.get("student_number"),
|
||||||
|
student_name: row.get("student_name"),
|
||||||
|
organization: row.get("organization"),
|
||||||
|
loan_date: row.get("loan_date"),
|
||||||
|
return_date: row.get("return_date"),
|
||||||
|
remarks: row.get("remarks"),
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
updated_at: row.get("updated_at"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_loan_with_item(&self, row: sqlx::sqlite::SqliteRow) -> LoanWithItem {
|
||||||
|
LoanWithItem {
|
||||||
|
id: row.get("id"),
|
||||||
|
item_id: row.get("item_id"),
|
||||||
|
item_name: row.get("item_name"),
|
||||||
|
item_label_id: row.get("item_label_id"),
|
||||||
|
student_number: row.get("student_number"),
|
||||||
|
student_name: row.get("student_name"),
|
||||||
|
organization: row.get("organization"),
|
||||||
|
loan_date: row.get("loan_date"),
|
||||||
|
return_date: row.get("return_date"),
|
||||||
|
remarks: row.get("remarks"),
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
updated_at: row.get("updated_at"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/services/mod.rs
Normal file
9
src/services/mod.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
pub mod item_service;
|
||||||
|
pub mod loan_service;
|
||||||
|
pub mod cable_color_service;
|
||||||
|
pub mod storage;
|
||||||
|
|
||||||
|
pub use item_service::*;
|
||||||
|
pub use loan_service::*;
|
||||||
|
pub use cable_color_service::*;
|
||||||
|
pub use storage::StorageService;
|
||||||
178
src/services/storage.rs
Normal file
178
src/services/storage.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
use aws_sdk_s3::Client as S3Client;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tokio::fs;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::config::{Config, StorageType};
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum StorageService {
|
||||||
|
S3(S3Storage),
|
||||||
|
Local(LocalStorage),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StorageService {
|
||||||
|
pub async fn new(config: &Config) -> AppResult<Self> {
|
||||||
|
match &config.storage.storage_type {
|
||||||
|
StorageType::S3 => Ok(StorageService::S3(S3Storage::new(config).await?)),
|
||||||
|
StorageType::Local => Ok(StorageService::Local(LocalStorage::new(config)?)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upload(&self, data: Vec<u8>, filename: &str, content_type: &str) -> AppResult<String> {
|
||||||
|
match self {
|
||||||
|
StorageService::S3(storage) => storage.upload(data, filename, content_type).await,
|
||||||
|
StorageService::Local(storage) => storage.upload(data, filename, content_type).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(&self, url: &str) -> AppResult<()> {
|
||||||
|
match self {
|
||||||
|
StorageService::S3(storage) => storage.delete(url).await,
|
||||||
|
StorageService::Local(storage) => storage.delete(url).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_url(&self, key: &str) -> String {
|
||||||
|
match self {
|
||||||
|
StorageService::S3(storage) => storage.get_url(key),
|
||||||
|
StorageService::Local(storage) => storage.get_url(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct S3Storage {
|
||||||
|
client: S3Client,
|
||||||
|
bucket_name: String,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl S3Storage {
|
||||||
|
pub async fn new(config: &Config) -> AppResult<Self> {
|
||||||
|
let s3_config = config.storage.s3.as_ref()
|
||||||
|
.ok_or_else(|| AppError::ConfigError(
|
||||||
|
config::ConfigError::Message("S3 configuration not found".to_string())
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let aws_config = aws_config::defaults(aws_config::BehaviorVersion::latest()).load().await;
|
||||||
|
let client = S3Client::new(&aws_config);
|
||||||
|
|
||||||
|
let base_url = format!("https://{}.s3.{}.amazonaws.com",
|
||||||
|
s3_config.bucket_name,
|
||||||
|
s3_config.region
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
bucket_name: s3_config.bucket_name.clone(),
|
||||||
|
base_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upload(&self, data: Vec<u8>, filename: &str, content_type: &str) -> AppResult<String> {
|
||||||
|
let key = format!("images/{}/{}", Uuid::new_v4(), filename);
|
||||||
|
|
||||||
|
self.client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&self.bucket_name)
|
||||||
|
.key(&key)
|
||||||
|
.body(data.into())
|
||||||
|
.content_type(content_type)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::StorageError(format!("Failed to upload to S3: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(self.get_url(&key))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(&self, url: &str) -> AppResult<()> {
|
||||||
|
// Extract key from URL
|
||||||
|
let key = url.strip_prefix(&format!("{}/", self.base_url))
|
||||||
|
.ok_or_else(|| AppError::StorageError("Invalid S3 URL".to_string()))?;
|
||||||
|
|
||||||
|
self.client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(&self.bucket_name)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::StorageError(format!("Failed to delete from S3: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_url(&self, key: &str) -> String {
|
||||||
|
format!("{}/{}", self.base_url, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct LocalStorage {
|
||||||
|
base_path: PathBuf,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalStorage {
|
||||||
|
pub fn new(config: &Config) -> AppResult<Self> {
|
||||||
|
let local_config = config.storage.local.as_ref()
|
||||||
|
.ok_or_else(|| AppError::ConfigError(
|
||||||
|
config::ConfigError::Message("Local storage configuration not found".to_string())
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let base_path = PathBuf::from(&local_config.path);
|
||||||
|
let base_url = format!("http://{}:{}/uploads",
|
||||||
|
config.server.host,
|
||||||
|
config.server.port
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
base_path,
|
||||||
|
base_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_directory(&self, path: &Path) -> AppResult<()> {
|
||||||
|
if !path.exists() {
|
||||||
|
fs::create_dir_all(path).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upload(&self, data: Vec<u8>, filename: &str, _content_type: &str) -> AppResult<String> {
|
||||||
|
let dir_name = Uuid::new_v4().to_string();
|
||||||
|
let dir_path = self.base_path.join(&dir_name);
|
||||||
|
|
||||||
|
self.ensure_directory(&dir_path).await?;
|
||||||
|
|
||||||
|
let file_path = dir_path.join(filename);
|
||||||
|
fs::write(&file_path, data).await?;
|
||||||
|
|
||||||
|
let relative_path = format!("{}/{}", dir_name, filename);
|
||||||
|
Ok(self.get_url(&relative_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(&self, url: &str) -> AppResult<()> {
|
||||||
|
// Extract relative path from URL
|
||||||
|
let relative_path = url.strip_prefix(&format!("{}/", self.base_url))
|
||||||
|
.ok_or_else(|| AppError::StorageError("Invalid local storage URL".to_string()))?;
|
||||||
|
|
||||||
|
let file_path = self.base_path.join(relative_path);
|
||||||
|
|
||||||
|
if file_path.exists() {
|
||||||
|
fs::remove_file(&file_path).await?;
|
||||||
|
|
||||||
|
// Try to remove empty parent directory
|
||||||
|
if let Some(parent) = file_path.parent() {
|
||||||
|
let _ = fs::remove_dir(parent).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_url(&self, key: &str) -> String {
|
||||||
|
format!("{}/{}", self.base_url, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
1
test_item.json
Normal file
1
test_item.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"name":"テストマイク","label_id":"MIC001","model_number":"Sony WM-1000XM4","remarks":"テスト用","purchase_year":2023,"purchase_amount":35000.0,"durability_years":5,"is_depreciation_target":true,"qr_code_type":"qr"}
|
||||||
Loading…
Add table
Reference in a new issue