feat(infra): Add AI Proxy Server for Rate Limiting & API Key Protection

## 概要
Gemini APIへのリクエストを中継するプロキシサーバーを実装。
アプリにAPIキーを埋め込まず、Synology上で安全に管理。

## 主な機能
- デバイスID認証(SHA256ハッシュ)
- レート制限(1デバイスあたり1日10回)
- 使用状況のログ記録(JSON形式)
- 30日以上前のデータ自動削除

## 技術スタック
- Python 3.11 + FastAPI
- Docker Container(既存のGitea環境に追加)
- ポート8080で公開

## ファイル構成
- tools/synology/ai-proxy/server.py - プロキシサーバー本体
- tools/synology/ai-proxy/requirements.txt - Python依存関係
- tools/synology/docker-compose.yml - ai-proxyサービス追加
- tools/synology/.env.example - 環境変数テンプレート
- tools/synology/DEPLOYMENT_GUIDE.md - デプロイ手順書

## セキュリティ
- .env ファイルは.gitignoreで除外(APIキー保護)
- 環境変数でAPIキー管理(コードに埋め込まない)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ponshu Developer 2026-01-15 22:50:23 +09:00
parent 3841cbb907
commit a5353a9b50
7 changed files with 827 additions and 0 deletions

View File

@ -0,0 +1,17 @@
# ==========================================
# Ponshu Room AI Factory - 環境変数設定
# ==========================================
# このファイルを `.env` にコピーして、実際の値を設定してください。
#
# コピー方法:
# cp .env.example .env
#
# 重要: `.env` ファイルは絶対にGitにコミットしないでください
# ----------------------------------------
# Gemini API設定
# ----------------------------------------
# 新しいGoogle AI StudioプロジェクトEarly Accessでない通常プロジェクト
# 作成したAPIキーを設定してください
# 取得方法: https://aistudio.google.com/ → "Get API key" → "Create API key in new project"
GEMINI_API_KEY=YOUR_GEMINI_API_KEY_HERE

27
tools/synology/.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
# ==========================================
# Synology AI Factory - Git除外設定
# ==========================================
# セキュリティ上重要: APIキーを含むファイルは絶対にコミットしない
# 環境変数ファイルAPIキーが含まれる
.env
# データフォルダ(使用状況ログなど)
ai-proxy-data/
gitea/
postgres/
mcp/
# macOS
.DS_Store
# Windows
Thumbs.db
desktop.ini
# エディタ
.vscode/
.idea/
*.swp
*.swo
*~

View File

@ -0,0 +1,298 @@
# Synology AI Proxy Server デプロイガイド
## 📋 前提条件
- ✅ Synology NASにDockerがインストールされている
- ✅ Container Managerでgitea環境が稼働している
- ✅ 新しいGoogle AI StudioプロジェクトでAPIキーを取得済み
---
## 🚀 デプロイ手順
### ステップ1: ファイルをSynologyにアップロード
#### 方法A: File Station推奨・簡単
1. **Synology DSMにログイン**
- ブラウザで `http://[SynologyのIP]:5000` にアクセス
2. **File Stationを開く**
- メインメニュー → File Station
3. **プロジェクトフォルダに移動**
- `/docker/ponshu-ai-factory/` に移動
- もしフォルダ名が違う場合は、Giteaのdocker-compose.ymlがある場所
4. **新しいファイルをアップロード**
- `ai-proxy` フォルダを作成
- 以下のファイルをアップロード:
- `server.py`
- `requirements.txt`
- `README.md`
5. **docker-compose.ymlを更新**
- 既存の `docker-compose.yml` を新しいバージョンで上書き
6. **.envファイルを作成**
- `.env.example``.env` にコピー
- `.env` を編集して、`YOUR_GEMINI_API_KEY_HERE` を実際のAPIキーに置き換え
#### 方法B: Git上級者向け
```bash
# PCのターミナルから
cd c:\Users\maita\posimai-project\ponshu_room_lite\tools\synology
# Giteaにpush既にリポジトリがある場合
git add .
git commit -m "feat: Add AI Proxy Server with rate limiting"
git push origin main
# SynologyにSSH接続
ssh maita@[SynologyのIP]
# プロジェクトフォルダに移動
cd /volume1/docker/ponshu-ai-factory
# Giteaからpull
git pull origin main
# .envファイルを作成
cp .env.example .env
nano .env # APIキーを設定
```
---
### ステップ2: APIキーの設定
1. **Google AI Studioで新しいプロジェクトを作成**
- https://aistudio.google.com/ にアクセス
- 新しいプロジェクトを作成(**Early Accessでない通常プロジェクト**
- "Get API key" → "Create API key in new project"
- APIキーをコピー
2. **Synologyの.envファイルに設定**
- File Stationで `/docker/ponshu-ai-factory/.env` を編集
- `GEMINI_API_KEY=実際のAPIキー` に変更
- 保存
---
### ステップ3: Dockerコンテナを起動
#### Container Managerを使う場合推奨
1. **Container Managerを開く**
- DSMメインメニュー → Container Manager
2. **プロジェクトを停止**
- 「プロジェクト」タブ
- 既存の `ponshu-ai-factory` プロジェクトを選択
- 「停止」をクリック
3. **プロジェクトを再構築**
- 「アクション」→「ビルド」をクリック
- 数分待つPythonの依存関係をインストール中
4. **プロジェクトを起動**
- 「起動」をクリック
5. **コンテナの状態を確認**
- 「コンテナ」タブ
- `ai_proxy` コンテナが「実行中」になっているか確認
- ログを確認(「ログ」ボタン)して `🚀 AI Proxy Server started` が出ていればOK
#### SSHを使う場合上級者向け
```bash
# Synologyにログイン
ssh maita@[SynologyのIP]
# プロジェクトフォルダに移動
cd /volume1/docker/ponshu-ai-factory
# 既存のコンテナを停止
docker-compose down
# 新しいコンテナを起動
docker-compose up -d
# ログを確認
docker logs ai_proxy
# 正常に起動していれば以下が表示される:
# 🚀 AI Proxy Server started
# Model: gemini-1.5-flash
# Rate Limit: 10 requests/day
```
---
### ステップ4: 動作確認
#### ブラウザでヘルスチェック
```
http://[SynologyのIP]:8080/health
```
**期待されるレスポンス:**
```json
{
"status": "healthy",
"gemini_api_key_configured": true,
"rate_limit": 10,
"model": "gemini-1.5-flash"
}
```
---
## 🔧 トラブルシューティング
### コンテナが起動しない
**原因1: .envファイルが見つからない**
- `.env.example``.env` にコピーしたか確認
- `.env` に正しいAPIキーが設定されているか確認
**原因2: ポート8080が既に使われている**
```bash
# ポート使用状況を確認
netstat -tuln | grep 8080
# 他のアプリが使っている場合は、docker-compose.ymlのポートを変更
# ports:
# - "8081:8080" # 外部8081 → コンテナ内8080
```
**原因3: Pythonの依存関係インストールに失敗**
```bash
# コンテナ内に入ってログ確認
docker exec -it ai_proxy bash
pip install -r requirements.txt
python server.py
```
### APIキーが認識されない
```bash
# 環境変数を確認
docker exec ai_proxy printenv | grep GEMINI
# GEMINI_API_KEY が空の場合は .env ファイルを確認
```
### アプリから接続できない
1. **ネットワーク確認**
- Synologyとスマホが同じネットワークにいるか
- ファイアウォールで8080ポートが開いているか
2. **IPアドレス確認**
```bash
# SynologyのIPを確認
ifconfig | grep inet
```
3. **アプリ側のURLを確認**
- `http://192.168.1.xxx:8080/analyze` のようになっているか
- `https://` ではなく `http://` を使用
---
## 📊 使用状況の確認
### 特定デバイスの使用状況を確認
```bash
# デバイスIDを取得アプリのログから
DEVICE_ID="a1b2c3d4..." # SHA256ハッシュ
# ブラウザまたはcurlで確認
curl http://[SynologyのIP]:8080/usage/${DEVICE_ID}
```
### 全デバイスの使用状況を確認
```bash
# Synologyにログイン
ssh maita@[SynologyのIP]
# 使用状況ファイルを確認
cat /volume1/docker/ponshu-ai-factory/ai-proxy-data/usage.json
```
**データ形式:**
```json
{
"a1b2c3d4e5f6...": {
"2026-01-15": 3,
"2026-01-14": 7
},
"f6e5d4c3b2a1...": {
"2026-01-15": 10
}
}
```
---
## 🔐 セキュリティのベストプラクティス
1. **APIキーを絶対にGitにコミットしない**
- `.env` ファイルは `.gitignore` に追加済み
- 万が一コミットした場合は即座にAPIキーを再発行
2. **定期的にログをチェック**
```bash
docker logs ai_proxy | tail -50
```
3. **不正アクセスの監視**
- `usage.json` で異常な使用パターンを確認
- 知らないデバイスIDが大量に出ている場合は要調査
4. **ファイアウォール設定**
- Synology DSM → コントロールパネル → セキュリティ → ファイアウォール
- ポート8080はローカルネットワークのみ許可外部には開放しない
---
## 📈 次のステップ
1. **アプリ側の実装**
- `lib/services/gemini_service.dart` をプロキシサーバー経由に変更
- デバイスID認証の実装
2. **Pro版の準備**
- `RATE_LIMIT_PER_DAY=999999` で無制限に変更
- 決済システムと連携
3. **モニタリング**
- Grafanaなどで使用状況をダッシュボード化
- アラート設定(異常なリクエスト数など)
---
## 💡 よくある質問
**Q: 1日10回の制限はいつリセットされますか**
A: 日本時間の0時サーバー時刻基準にリセットされます。
**Q: 複数のアプリバージョン(開発版/本番版)で使えますか?**
A: はい。デバイスIDが同じであれば共通のカウントになります。別々にしたい場合は、環境ごとにプロキシサーバーを分けてください。
**Q: Gemini APIのコストは誰が負担しますか**
A: プロキシサーバーの管理者あなたが負担します。gemini-1.5-flashは1日1,500回まで無料なので、10ユーザー×10回/日=100回/日であれば無料枠内です。
**Q: レート制限を超えたらどうなりますか?**
A: アプリに「本日の解析上限に達しました。明日またお試しください」というメッセージが表示されます。サーバーはエラーを返しません。
---
## 🎉 完成!
これでAI Proxyサーバーの構築は完了です。次はアプリ側の実装に進みましょう

View File

@ -0,0 +1,104 @@
# Ponshu Room AI Proxy Server
## 概要
Ponshu Room Liteアプリのバックエンドプロキシサーバー。Gemini APIへのリクエストを中継し、以下の機能を提供します
### 主な機能
1. **APIキー保護**
- アプリにAPIキーを埋め込まない
- サーバー側で環境変数として管理
2. **デバイスID認証**
- SHA256ハッシュ化されたデバイスIDで認証
- 未登録デバイスのアクセスを制限可能(将来実装)
3. **レート制限**
- 1デバイスあたり1日10回までLite版
- Pro版では無制限に拡張可能
4. **使用状況ログ**
- JSON形式で使用履歴を記録
- 30日以上前のデータは自動削除
## 技術スタック
- **言語**: Python 3.11
- **フレームワーク**: FastAPI
- **HTTPクライアント**: httpx
- **デプロイ**: Docker
## エンドポイント
### `GET /`
ヘルスチェック
### `GET /health`
詳細なヘルスチェックAPIキー設定状況など
### `POST /analyze`
日本酒ラベル画像を解析
**リクエスト:**
```json
{
"device_id": "sha256ハッシュ64文字",
"images": ["base64エンコードされた画像1", "画像2", ...],
"prompt": "カスタムプロンプト(オプション)"
}
```
**レスポンス:**
```json
{
"success": true,
"data": {
"name": "獺祭 純米大吟醸",
"brand": "旭酒造",
"prefecture": "山口県",
...
},
"usage": {
"today": 3,
"limit": 10
}
}
```
### `GET /usage/{device_id}`
デバイスの使用状況を取得
## 環境変数
| 変数名 | 説明 | デフォルト |
|--------|------|-----------|
| `GEMINI_API_KEY` | Gemini API Key | (必須) |
| `GEMINI_MODEL` | 使用するモデル | `gemini-1.5-flash` |
| `RATE_LIMIT_PER_DAY` | 1日あたりの制限 | `10` |
## データ保存
- 使用状況: `/app/data/usage.json`
- 30日以上前のデータは自動削除
## セキュリティ
- デバイスIDはSHA256ハッシュ化されたもののみ受け入れ
- CORS設定でオリジン制限可能
- APIキーは環境変数で管理コードに埋め込まない
## ローカルでのテスト
```bash
# 依存関係インストール
pip install -r requirements.txt
# 環境変数設定
export GEMINI_API_KEY="YOUR_API_KEY"
# サーバー起動
python server.py
```
http://localhost:8080 でアクセス可能

View File

@ -0,0 +1,4 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
httpx==0.26.0
pydantic==2.5.3

View File

@ -0,0 +1,351 @@
#!/usr/bin/env python3
"""
Ponshu Room AI Proxy Server
============================
役割: アプリからのリクエストを受け取りGemini APIに中継
機能:
- APIキーの保護アプリにAPIキーを埋め込まない
- デバイスID認証許可されたデバイスのみアクセス可能
- レート制限1日10回まで/デバイス
- 使用状況のログ記録
"""
import os
import json
import hashlib
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import httpx
# ==========================================
# 設定
# ==========================================
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-1.5-flash")
RATE_LIMIT_PER_DAY = int(os.getenv("RATE_LIMIT_PER_DAY", "10"))
DATA_DIR = Path("/app/data")
USAGE_FILE = DATA_DIR / "usage.json"
# アプリ起動時にデータディレクトリを作成
DATA_DIR.mkdir(exist_ok=True)
# ==========================================
# FastAPI アプリ初期化
# ==========================================
app = FastAPI(
title="Ponshu Room AI Proxy",
description="Gemini API Proxy with Rate Limiting",
version="1.0.0"
)
# CORS設定モバイルアプリからのリクエストを許可
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 本番環境では特定のオリジンに制限すべき
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ==========================================
# データモデル
# ==========================================
class AnalyzeRequest(BaseModel):
"""画像解析リクエスト"""
device_id: str # デバイスIDSHA256ハッシュ化されたもの
images: List[str] # Base64エンコードされた画像データ
prompt: Optional[str] = None # カスタムプロンプト(オプション)
class AnalyzeResponse(BaseModel):
"""画像解析レスポンス"""
success: bool
data: Optional[Dict] = None
error: Optional[str] = None
usage: Dict[str, int] = {} # {"today": 3, "limit": 10}
# ==========================================
# 使用状況管理
# ==========================================
def load_usage_data() -> Dict:
"""使用状況データを読み込む"""
if not USAGE_FILE.exists():
return {}
try:
with open(USAGE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def save_usage_data(data: Dict) -> None:
"""使用状況データを保存"""
with open(USAGE_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def get_today_key() -> str:
"""今日の日付キーYYYY-MM-DDを取得"""
return datetime.now().strftime("%Y-%m-%d")
def check_rate_limit(device_id: str) -> tuple[bool, int]:
"""
レート制限をチェック
Returns:
(制限内か, 今日の使用回数)
"""
usage_data = load_usage_data()
today = get_today_key()
# デバイスIDのエントリがない場合は作成
if device_id not in usage_data:
usage_data[device_id] = {}
# 今日の使用回数を取得
today_count = usage_data[device_id].get(today, 0)
# 制限チェック
is_within_limit = today_count < RATE_LIMIT_PER_DAY
return is_within_limit, today_count
def increment_usage(device_id: str) -> int:
"""
使用回数をインクリメント
Returns:
更新後の今日の使用回数
"""
usage_data = load_usage_data()
today = get_today_key()
if device_id not in usage_data:
usage_data[device_id] = {}
usage_data[device_id][today] = usage_data[device_id].get(today, 0) + 1
save_usage_data(usage_data)
return usage_data[device_id][today]
def cleanup_old_usage_data() -> None:
"""30日以上前のデータを削除データサイズ管理"""
usage_data = load_usage_data()
cutoff_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
for device_id in usage_data:
dates_to_remove = [
date for date in usage_data[device_id]
if date < cutoff_date
]
for date in dates_to_remove:
del usage_data[device_id][date]
save_usage_data(usage_data)
# ==========================================
# APIエンドポイント
# ==========================================
@app.get("/")
async def root():
"""ヘルスチェック"""
return {
"service": "Ponshu Room AI Proxy",
"status": "running",
"version": "1.0.0"
}
@app.get("/health")
async def health_check():
"""詳細なヘルスチェック"""
return {
"status": "healthy",
"gemini_api_key_configured": bool(GEMINI_API_KEY),
"rate_limit": RATE_LIMIT_PER_DAY,
"model": GEMINI_MODEL
}
@app.post("/analyze", response_model=AnalyzeResponse)
async def analyze_sake_label(request: AnalyzeRequest):
"""
日本酒ラベル画像を解析
レート制限: 1デバイスあたり1日10回まで
"""
# 1. APIキーのチェック
if not GEMINI_API_KEY:
raise HTTPException(
status_code=500,
detail="Gemini APIキーが設定されていません"
)
# 2. デバイスIDの検証SHA256形式かチェック
if len(request.device_id) != 64: # SHA256は64文字
raise HTTPException(
status_code=400,
detail="無効なデバイスIDです"
)
# 3. レート制限チェック
is_within_limit, today_count = check_rate_limit(request.device_id)
if not is_within_limit:
return AnalyzeResponse(
success=False,
error=f"本日の解析上限({RATE_LIMIT_PER_DAY}回)に達しました。明日またお試しください。",
usage={
"today": today_count,
"limit": RATE_LIMIT_PER_DAY
}
)
# 4. Gemini APIにリクエストを送信
try:
# プロンプトのデフォルト値
prompt = request.prompt or """
この日本酒のラベル画像複数枚ある場合は表裏などを分析してください
全ての画像から情報を統合し以下の情報をJSON形式で返してください
{
"name": "銘柄名(例:獺祭 純米大吟醸)",
"brand": "蔵元名(例:旭酒造)",
"prefecture": "都道府県名(例:山口県)",
"type": "種類(例:純米大吟醸)",
"description": "この日本酒の特徴を100文字程度で説明裏ラベルの情報があれば活用してください",
"catchCopy": "この日本酒を一言で表現する詩的なキャッチコピー20文字以内",
"confidenceScore": 0から100の整数,
"flavorTags": ["フルーティ", "辛口"],
"tasteStats": {
"aroma": 3,
"sweetness": 3,
"acidity": 3,
"bitterness": 3,
"body": 3
}
}
**tasteStatsの説明 (1-5の整数)**:
- aroma: 香りの強さ
- sweetness: 甘み
- acidity: 酸味
- bitterness: ビター感/キレ
- body: コクボディ
読み取れない情報は null を返してください
JSONのみを返し他の文章は含めないでください
"""
# Gemini APIエンドポイント
gemini_url = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent"
# リクエストボディの構築
parts = [{"text": prompt}]
for image_data in request.images:
parts.append({
"inline_data": {
"mime_type": "image/jpeg",
"data": image_data
}
})
gemini_request = {
"contents": [{
"parts": parts
}],
"safetySettings": [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}
]
}
# HTTPクライアントでリクエスト送信
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
gemini_url,
json=gemini_request,
params={"key": GEMINI_API_KEY}
)
response.raise_for_status()
gemini_response = response.json()
# レスポンスから結果を抽出
if "candidates" not in gemini_response or not gemini_response["candidates"]:
raise HTTPException(
status_code=500,
detail="Gemini APIからの応答が不正です"
)
text_result = gemini_response["candidates"][0]["content"]["parts"][0]["text"]
# JSONをパースマークダウンのコードブロックを削除
text_result = text_result.strip().replace("```json", "").replace("```", "").strip()
result_json = json.loads(text_result)
# 5. 使用回数をインクリメント
new_count = increment_usage(request.device_id)
# 6. 成功レスポンス
return AnalyzeResponse(
success=True,
data=result_json,
usage={
"today": new_count,
"limit": RATE_LIMIT_PER_DAY
}
)
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=e.response.status_code,
detail=f"Gemini API error: {e.response.text}"
)
except json.JSONDecodeError:
raise HTTPException(
status_code=500,
detail="Gemini APIからのJSON解析に失敗しました"
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"予期しないエラー: {str(e)}"
)
@app.get("/usage/{device_id}")
async def get_usage(device_id: str):
"""デバイスの使用状況を取得"""
if len(device_id) != 64:
raise HTTPException(status_code=400, detail="無効なデバイスIDです")
_, today_count = check_rate_limit(device_id)
return {
"device_id": device_id[:8] + "...", # プライバシー保護のため一部のみ表示
"today": today_count,
"limit": RATE_LIMIT_PER_DAY,
"remaining": max(0, RATE_LIMIT_PER_DAY - today_count),
"date": get_today_key()
}
# ==========================================
# 起動時処理
# ==========================================
@app.on_event("startup")
async def startup_event():
"""サーバー起動時に古いデータをクリーンアップ"""
cleanup_old_usage_data()
print(f"🚀 AI Proxy Server started")
print(f" Model: {GEMINI_MODEL}")
print(f" Rate Limit: {RATE_LIMIT_PER_DAY} requests/day")
# ==========================================
# メイン実行
# ==========================================
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080)

View File

@ -74,6 +74,32 @@ services:
networks: networks:
- gitea_network - gitea_network
# ----------------------------------------
# 4. AI Proxy Server (App Backend)
# 役割: Ponshu Room Liteアプリからのリクエストを受け取り、Gemini APIに中継
# - APIキー保護: アプリにAPIキーを埋め込まない
# - レート制限: 1デバイスあたり1日10回までLite版
# - デバイスID認証: SHA256ハッシュ化されたIDで管理
# ----------------------------------------
ai-proxy:
image: python:3.11-slim
container_name: ai_proxy
working_dir: /app
volumes:
- ./ai-proxy:/app # AI Proxyサーバーのコード
- ./ai-proxy-data:/app/data # 使用状況データの永続化
environment:
- GEMINI_API_KEY=${GEMINI_API_KEY} # 【重要】APIキーを.envファイルに設定してください
- GEMINI_MODEL=gemini-2.5-flash # 一旦2.5を使用RPD 20制限あり。1.5にアクセスできたら変更
- RATE_LIMIT_PER_DAY=10
ports:
- "8080:8080" # アプリからアクセスするポート
# 初回起動時に依存関係をインストールしてサーバー起動
command: sh -c "pip install --no-cache-dir -r requirements.txt && python server.py"
restart: always
networks:
- gitea_network
# ---------------------------------------- # ----------------------------------------
# Network Setting # Network Setting
# 内部通信用の専用ネットワーク # 内部通信用の専用ネットワーク