From 10f772942a30631081ff2d0b1491f7f0aeab9237 Mon Sep 17 00:00:00 2001 From: Ponshu Developer Date: Sat, 21 Feb 2026 19:35:59 +0900 Subject: [PATCH] feat: Implement Redis persistence for Synology Proxy rate limiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Infrastructure Improvements: - Add Redis container to tools/proxy/docker-compose.yml with AOF persistence - Migrate rate limiting from in-memory to Redis-based storage - Add TTL-based daily quota reset (expires at midnight) - Implement health checks for Redis and Proxy containers Proxy Server Changes (tools/proxy/server.js): - Add redis client with async connection handling - Replace usageStore object with Redis GET/INCR/EXPIRE operations - Add responseMimeType: 'application/json' to Gemini client config (fixes Markdown response bug) - Add comprehensive debug logging for JSON parsing issues Flutter App Configuration: - Change Secrets.useProxy defaultValue from false to true - Development builds now use local Synology proxy by default - Release builds can override with --dart-define=USE_PROXY=false Documentation: - Add REDIS_MIGRATION_GUIDE.md with step-by-step migration instructions - Add tools/proxy/README.md with architecture overview - Create .env.example template for secrets configuration - Update PROJECT_TODO.md to mark H3 (Proxy永続化) as in progress Dependencies: - Add redis@^4.7.0 to package.json This resolves the critical tech debt where rate limits reset on container restart. Redis AOF persistence ensures quota tracking survives server reboots. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/PROJECT_TODO.md | 178 ++++++---------- lib/secrets.dart | 3 +- tools/proxy/.env.example | 13 ++ tools/proxy/.gitignore | 12 ++ tools/proxy/README.md | 140 +++++++++++++ tools/proxy/REDIS_MIGRATION_GUIDE.md | 290 +++++++++++++++++++++++++++ tools/proxy/docker-compose.yml | 53 +++++ tools/proxy/package.json | 3 +- tools/proxy/server.js | 154 ++++++++++---- 9 files changed, 685 insertions(+), 161 deletions(-) create mode 100644 tools/proxy/.env.example create mode 100644 tools/proxy/.gitignore create mode 100644 tools/proxy/README.md create mode 100644 tools/proxy/REDIS_MIGRATION_GUIDE.md create mode 100644 tools/proxy/docker-compose.yml diff --git a/docs/PROJECT_TODO.md b/docs/PROJECT_TODO.md index faaaa89..1559ea0 100644 --- a/docs/PROJECT_TODO.md +++ b/docs/PROJECT_TODO.md @@ -1,145 +1,85 @@ -# 📋 Ponshu Room Lite - Current TODO List +# 📋 Ponshu Room Lite - Current TODO List & Roadmap -**Last Updated**: 2026-01-21 -**Status**: Active Task Tracking +**Last Updated**: 2026-02-21 (v1.0.16) +**Status**: Active Task Tracking (Based on Claude's Comprehensive Review) **For**: Multi-AI Collaboration (Claude + Antigravity + Gemini) --- -## 🔴 CRITICAL: Deferred Tasks (DO NOT FORGET) +## 🔴 High Priority (品質・配布に直結 / 即時対応推奨) -### Dark Mode Visibility Fixes (Phase 1 - INCOMPLETE) - -- [x] ✅ Create Dark Mode guidelines ([DARK_MODE_COLOR_GUIDELINES.md](DARK_MODE_COLOR_GUIDELINES.md)) -- [x] ✅ Redesign app_theme.dart to use Material 3 ColorScheme -- [x] ✅ Fix guide_screen.dart (section headers to secondary color) -- [x] ✅ Fix soul_screen.dart (10+ manual dark mode checks removed) -- [ ] ⏸️ **DEFERRED**: Fix [sake_detail_screen.dart](../lib/screens/sake_detail_screen.dart) - - **Why Deferred**: 1500+ lines, recently modified, needs careful review - - **Issues**: 9 instances of `Theme.of(context).primaryColor` usage - - **Location**: Lines need investigation with `Grep` tool - - **Priority**: High (core feature screen) - - **Estimated Time**: 2-3 hours - - **Action Plan**: - 1. Search for all `primaryColor` instances in file - 2. Replace with appropriate `colorScheme` properties - 3. Test thoroughly in both Light and Dark modes - 4. Verify all UI elements (buttons, icons, charts, dialogs) - -- [ ] ⏸️ **DEFERRED**: Fix shop_settings_screen.dart - - **Issues**: Manual dark mode checks present - - **Priority**: Medium - - **Estimated Time**: 1 hour - -- [ ] ⏸️ **DEFERRED**: Fix remaining widgets with `primaryColor` usage - - **Files**: badge_case.dart, level_title_card.dart, sake_grid_item.dart, etc. - - **Count**: ~40 instances across multiple files - - **Priority**: Low-Medium - - **Estimated Time**: 4-6 hours total - - **Strategy**: Fix incrementally during feature work +- [ ] **H1. ダークモード完全対応** (推定: 6-8時間) + - **対象**: `sake_detail_screen.dart:794` - 9箇所の `Theme.of(context).primaryColor` を `colorScheme` に置換。コアスクリーンでの視認性問題のため優先。 +- [ ] **H2. 大規模ファイルのリファクタリング** (推定: 8-12時間) + - `camera_screen.dart` (966行): `CameraControls`, `PhotoPreview`, `GalleryPicker` に分割。 + - `sake_detail_screen.dart` (794行): 分割済みだが監視継続 (`BasicInfo`, `TasteChart`, `Sakenowa`, `Pricing`)。 + - `menu_pricing_screen.dart` (650行): `PricingInputSection`, `PricingPreview` に分割。 +- [ ] **H3. Synology Proxy永続化対応** (推定: 4-6時間) + - **現状**: In-Memoryのため、再起動で利用回数クォータがリセットされる。 + - **解決策**: Redis導入(推奨)またはファイルベースの永続化。 --- -## 🟡 Phase 2: Planned Features (READY TO START) +## 🟡 Medium Priority (UX改善・機能追加) -See detailed implementation plan: [PHASE_2_IMPLEMENTATION_PLAN.md](PHASE_2_IMPLEMENTATION_PLAN.md) - -**Summary**: -1. **Help Button Placement** (Pattern C: Hybrid approach) - 6 hours -2. **AI-Powered "あわせて飲みたい" Recommendations** - 12 hours -3. **AI Analysis Info Editing** - 8 hours - -**Total Estimated Time**: 26 hours +- [ ] **M1. AI「あわせて飲みたい」機能実装** (推定: 12-16時間) + - **要件**: 履歴分析、未登録銘柄からの推薦文生成。ソムリエタブのプレースホルダーを実稼働させる。 +- [ ] **M2. チャート手動編集UIの完成** (推定: 8-10時間) + - **内容**: AI生成の5軸チャートをユーザーが微調整できる機能。 +- [ ] **M3. ヘルプボタンの配置改善** (推定: 6時間) + - **方針**: Pattern C (Hybrid approach) - 各画面の状況に応じて配置。 +- [ ] **M4. Pro版機能の実装** (推定: 20-24時間) + - **要件**: QRスキャン連携、Instagram投稿生成機能、売上アナリティクスダッシュボード等。 --- -## 🟢 Phase 3: Technical Debt & Cleanup +## 🟢 Low Priority (技術的負債・長期改善) -### Coach Mark / Tutorial Cleanup -- [ ] Remove `hasSeenTutorial` from UserProfile model -- [ ] Remove `hasSeenCoachMarks` from UserProfile model -- [ ] Remove tutorial-related methods from theme_provider.dart -- [ ] Remove tutorial-related images/assets -- **Why**: Tutorial service was deleted due to persistence bugs -- **Priority**: Low (not breaking anything, just cluttering code) -- **Estimated Time**: 1 hour - -### Flutter Analyze Warnings -- [ ] Fix 49 warnings (see `flutter analyze` output) - - Unused imports - - Deprecated API usage - - Type warnings -- **Priority**: Medium -- **Estimated Time**: 2 hours - -### Image Compression -- [ ] Implement real image compression (not just file copy) -- **Current Issue**: ImageCompressionService just copies files -- **Solution**: Use flutter_image_compress or similar -- **Priority**: Medium (affects storage usage) -- **Estimated Time**: 3 hours +- [ ] **L1. debugPrint整理** (推定: 3-4時間) + - **対象**: 221箇所(23ファイル)。`if (kDebugMode)` で囲む(主に `gemini_service.dart`, `analysis_cache_service.dart`)。 +- [ ] **L2. ハードコード色の段階的修正** (推定: 10-15時間) + - **対象**: 意図的な例外(カメラビューなど)を除くハードコード色。`onboarding_dialog.dart` や `pending_analysis_screen.dart` などから優先。 +- [ ] **L3. 真の画像圧縮実装** (推定: 3-4時間) + - **現状**: コピーのみの `ImageCompressionService` を `flutter_image_compress` などの圧縮処理に置き換え。 +- [ ] **L4. Tutorial/CoachMark残存コードの削除** (推定: 1-2時間) + - **対象**: `UserProfile` に存在したチュートリアル系不要フィールドと関連アセットの削除の完全完了。 +- [ ] **L5. テストコード導入** (推定: 8-12時間) + - `SakeItem` JSON変換、`LevelCalculator`、`GeminiService` 等々のカバレッジ向上。 +- [ ] **L6. UI/UXのさらなる改善** + - バッジアンロックモーダル、スキャン時のアニメーション、タブレット対応(レスポンシブ)等。 --- -## 📚 Documentation Status +## 🔮 Future / 長期構想 (Phase 3+) -### ✅ Complete & Up-to-date -- [DARK_MODE_COLOR_GUIDELINES.md](DARK_MODE_COLOR_GUIDELINES.md) - Dark Mode implementation guide -- [PROJECT_BACKLOG_MASTER.md](PROJECT_BACKLOG_MASTER.md) - High-level roadmap -- [UI_UX_BACKLOG.md](UI_UX_BACKLOG.md) - UI/UX improvement tracking -- [CURSOR_CHAT_MASTER_CONTEXT.md](architecture/CURSOR_CHAT_MASTER_CONTEXT.md) - Antigravity handoff - -### 🔄 Needs Update -- [PROJECT_BACKLOG_MASTER.md](PROJECT_BACKLOG_MASTER.md) - Mark Dark Mode tasks as partially complete -- [UI_UX_BACKLOG.md](UI_UX_BACKLOG.md) - Update Coach Mark status - -### 📝 New Documents Created -- This file (PROJECT_TODO.md) -- [README.md](README.md) - Documentation index (TO BE CREATED) -- [PHASE_2_IMPLEMENTATION_PLAN.md](PHASE_2_IMPLEMENTATION_PLAN.md) (TO BE CREATED) +- [ ] **F1. Firebase同期** (16-20時間): クラウドバックアップ、複数デバイス同期。 +- [ ] **F2. Posimai Core Platform構築** (40時間〜): 日本酒・香道アプリの共通基盤化。 +- [ ] **F3. 多言語対応** (20時間): 英語・中国語対応(ARBファイル利用)。 +- [ ] **F4. 酒蔵マップ実データ統合** (12時間): プレースホルダーから実データへの連携、GPS連動等。 --- -## 🎯 Recommended Next Steps +## 🏗️ Synology環境の堅牢化 (運用サーバー対応) -For immediate work, choose ONE of the following: - -### Option A: Complete Dark Mode Fixes -1. Fix sake_detail_screen.dart (~2-3 hours) -2. Fix shop_settings_screen.dart (~1 hour) -3. Mark Dark Mode project as complete - -### Option B: Start Phase 2 Features -1. Implement Help Button Placement (~6 hours) -2. Creates user value immediately -3. Defer remaining Dark Mode fixes to incremental work - -### Option C: Quick Wins -1. Technical debt cleanup (~3 hours total) - - Coach mark removal - - Flutter analyze fixes -2. Builds confidence, cleans codebase - -**Recommended by Previous Claude Instance**: Option B (Phase 2) - Delivers user value while Dark Mode is "good enough" for now. +- [ ] **S1. Proxy Serverのレート制限永続化** (Critical / =H3) + - Container Managerで Redis コンテナを立ち上げ、Node.jsプロキシに接続して利用履歴を永続化。 +- [ ] **S2. バックアップ戦略の確立** (Critical) + - Hyper Backupでのプロジェクト全体の外部保存、Dokploy / Giteaの定期ボリュームバックアップの設定。 +- [ ] **S3. SSL/TLS対応** + - Tailscale Funnel や Synology Application Portal (リバースプロキシ) の構築。 +- [ ] **S4. 監視・アラート設定** + - Uptime Kuma などを導入し、ダウン時に通知を飛ばす体制づくり。 +- [ ] **S5-10. インフラのさらなる拡張** (Important/Nice to Have) + - CI/CD構築(Gitea Actions, Dokploy連携等)、共有基盤化、Webhook通知など。 --- -## 🚨 Known Issues to Avoid +## 📅 推奨作業順序 (今後2週間) -1. **Image Path Repair Service** - Works correctly after Day 4 bug fix -2. **Backup/Restore** - Functional, tested successfully -3. **Tutorial Service** - Deleted, do not attempt to restore -4. **Dark Mode** - Fixed for core screens, remaining issues documented above - ---- - -## 📞 For Future AI Collaborators - -- **Before modifying Dark Mode code**: Read [DARK_MODE_COLOR_GUIDELINES.md](DARK_MODE_COLOR_GUIDELINES.md) -- **Before adding new features**: Check this TODO and [PROJECT_BACKLOG_MASTER.md](PROJECT_BACKLOG_MASTER.md) -- **Before committing**: Run `flutter analyze` and fix new warnings -- **For documentation questions**: See [docs/README.md](README.md) (index file) - ---- - -*This is a living document. Update status after completing tasks.* +1. **品質安定化 (Week 1)** + - Day 1-2: Synology Proxy永続化 (S1 / H3) + - Day 3-4: ダークモード完全対応 (H1) + - Day 5: バックアップ戦略確立 (S2) +2. **機能拡張 or インフラ強化 (Week 2)** + - *Option A (機能優先)*: 推薦機能(M1) & チャート微調整(M2) + - *Option B (インフラ優先)*: Dokploy設定 & CI/CDテスト & 監視設定 diff --git a/lib/secrets.dart b/lib/secrets.dart index 2e46166..d1b79a3 100644 --- a/lib/secrets.dart +++ b/lib/secrets.dart @@ -24,9 +24,10 @@ class Secrets { /// AI Mode: Proxy(Home) vs Direct(Cloud) /// If false, connects directly to Google Gemini API (Works anywhere). /// Release build: --dart-define=USE_PROXY=true + /// Development default: true (use local Synology proxy) static const bool useProxy = bool.fromEnvironment( 'USE_PROXY', - defaultValue: false, + defaultValue: true, // ← 開発環境ではProxy使用 ); diff --git a/tools/proxy/.env.example b/tools/proxy/.env.example new file mode 100644 index 0000000..7949c93 --- /dev/null +++ b/tools/proxy/.env.example @@ -0,0 +1,13 @@ +# Gemini API Key +GEMINI_API_KEY=your_gemini_api_key_here + +# Proxy Authentication Token (recommended for security) +# Generate a random string: openssl rand -hex 32 +PROXY_AUTH_TOKEN=your_secure_random_token_here + +# Daily request limit per device +DAILY_LIMIT=50 + +# Redis connection settings (default values for Docker Compose) +REDIS_HOST=redis +REDIS_PORT=6379 diff --git a/tools/proxy/.gitignore b/tools/proxy/.gitignore new file mode 100644 index 0000000..5141e48 --- /dev/null +++ b/tools/proxy/.gitignore @@ -0,0 +1,12 @@ +# Environment variables (contains secrets) +.env + +# Node modules +node_modules/ + +# Logs +*.log +npm-debug.log* + +# Docker volumes +data/ diff --git a/tools/proxy/README.md b/tools/proxy/README.md new file mode 100644 index 0000000..83b8915 --- /dev/null +++ b/tools/proxy/README.md @@ -0,0 +1,140 @@ +# Ponshu Room Proxy Server (Redis永続化対応) + +Gemini APIキーを隠蔽し、デバイスごとの利用制限(レート制限)を管理するプロキシサーバー。 + +## 🎯 機能 + +- **APIキー保護**: クライアントアプリにAPIキーを含めず、プロキシサーバーのみが保持 +- **レート制限**: デバイスIDごとに1日あたりのリクエスト数を制限 +- **Redis永続化**: Dockerコンテナ再起動後もカウントを維持 +- **認証**: Bearer Token認証(オプション) +- **Dokploy対応**: 将来のDokploy移行に対応した構成 + +--- + +## 🚀 クイックスタート(Synology NAS) + +### 1. 環境変数ファイル作成 + +```bash +cp .env.example .env +``` + +`.env` を編集してAPIキーと認証トークンを設定: + +```env +GEMINI_API_KEY=AIzaSy...(あなたのAPIキー) +PROXY_AUTH_TOKEN=$(openssl rand -hex 32) # ランダム生成推奨 +DAILY_LIMIT=50 +``` + +### 2. Docker Composeで起動 + +```bash +docker-compose up -d +``` + +### 3. 動作確認 + +```bash +curl http://localhost:8080/health +# 期待される出力: OK +``` + +--- + +## 📊 Redis データ確認 + +```bash +# Redisコンテナに接続 +docker exec -it ponshu-redis redis-cli + +# 使用状況確認 +KEYS usage:* +GET "usage:device-123:2026-02-21" +TTL "usage:device-123:2026-02-21" +``` + +--- + +## 🔄 運用コマンド + +```bash +# ログ確認 +docker-compose logs -f proxy + +# 再起動 +docker-compose restart + +# 停止 +docker-compose stop + +# 完全削除(データも削除) +docker-compose down -v +``` + +--- + +## 📚 ドキュメント + +- **移行ガイド**: [REDIS_MIGRATION_GUIDE.md](REDIS_MIGRATION_GUIDE.md) - In-MemoryからRedis永続化への移行手順 +- **セットアップガイド**: [../../docs/SYNOLOGY_PROXY_SETUP.md](../../docs/SYNOLOGY_PROXY_SETUP.md) - 初回構築手順(旧版・参考用) + +--- + +## 🏗️ アーキテクチャ + +``` +Flutter App (Client) + ↓ HTTP POST /analyze + ↓ Authorization: Bearer +Proxy Server (Node.js/Express) + ↓ Rate Limit Check (Redis) + ↓ Gemini API Call +Gemini 2.5 Flash API +``` + +--- + +## 🔐 セキュリティ + +- `.env` ファイルは **Git管理しない**(`.gitignore`に追加済み) +- `PROXY_AUTH_TOKEN` は **長いランダム文字列** を使用 +- 外部公開する場合は **リバースプロキシ + HTTPS** 推奨 + +--- + +## 📝 環境変数 + +| 変数名 | 説明 | デフォルト | +|--------|------|-----------| +| `GEMINI_API_KEY` | Gemini APIキー(必須) | なし | +| `PROXY_AUTH_TOKEN` | Bearer Token認証(推奨) | なし(認証無効) | +| `DAILY_LIMIT` | 1日あたりのリクエスト上限 | 50 | +| `REDIS_HOST` | Redisホスト名 | `redis` | +| `REDIS_PORT` | Redisポート | `6379` | +| `PORT` | Proxy Serverポート | `8080` | + +--- + +## 🆘 トラブルシューティング + +### Q: `Redis connection failed` + +**解決策**: Redisコンテナが起動していることを確認 + +```bash +docker-compose ps +# ponshu-redis が "Up (healthy)" になっているか確認 +``` + +### Q: Flutter アプリから接続できない + +**解決策**: +1. `.env` の `PROXY_AUTH_TOKEN` を確認 +2. Flutter の `lib/secrets.local.dart` で同じトークンを設定 +3. Synology NASのIPアドレス(`100.76.7.3`)が正しいか確認 + +--- + +**Last Updated**: 2026-02-21 diff --git a/tools/proxy/REDIS_MIGRATION_GUIDE.md b/tools/proxy/REDIS_MIGRATION_GUIDE.md new file mode 100644 index 0000000..aa07f6e --- /dev/null +++ b/tools/proxy/REDIS_MIGRATION_GUIDE.md @@ -0,0 +1,290 @@ +# Synology Proxy Server - Redis永続化移行ガイド + +## 📋 概要 + +このガイドでは、Synology NAS上のProxy Serverを**In-Memory方式からRedis永続化方式**に移行する手順を説明します。 + +### 移行の目的 +- **永続化**: Dockerコンテナ再起動後もレート制限カウントが維持される +- **Dokploy対応**: 将来のDokploy導入時にそのまま移行可能な構成 +- **複数アプリ対応**: ダッシュボードWebシステム等、他プロジェクトでもRedisを共有可能 + +--- + +## 🛠️ 前提条件 + +- Synology NAS(Container Manager インストール済み) +- 既存のProxy Serverコンテナが稼働中(`ponshu-proxy-server`) +- SSH接続可能な環境(または File Station経由のファイル操作) + +--- + +## 📂 ステップ1: 既存コンテナの停止と削除 + +### 1-1. Container Managerで既存コンテナを停止 + +1. Synology DSM → **Container Manager** を開く +2. **コンテナ** タブで `ponshu-proxy-server` を選択 +3. **アクション** → **停止** をクリック +4. 停止後、**削除** をクリック(イメージは削除しない) + +**注意**: 既存のレート制限カウントは削除されますが、これは移行の性質上避けられません。 + +--- + +## 📂 ステップ2: 新しいファイルをSynologyに配置 + +### 2-1. File Stationでファイルをアップロード + +PC上の `tools/proxy/` フォルダから、以下のファイルをSynologyの `/docker/ponshu_proxy/` にアップロードします。 + +**アップロード対象**: +- `docker-compose.yml` ← **新規追加** +- `server.js` ← **更新版** +- `package.json` ← **更新版(Redis依存追加)** +- `Dockerfile` ← 変更なし +- `.env` ← **新規作成(次ステップ)** + +### 2-2. `.env` ファイルを作成 + +File Stationで `/docker/ponshu_proxy/.env` を新規作成し、以下の内容を記述します。 + +```env +# Gemini API Key +GEMINI_API_KEY=AIzaSy...(あなたのAPIキー) + +# Proxy認証トークン(必須推奨) +PROXY_AUTH_TOKEN=your-secure-token-here + +# 1日あたりの解析回数上限 +DAILY_LIMIT=50 + +# Redis接続設定(Docker Compose使用時はデフォルトでOK) +REDIS_HOST=redis +REDIS_PORT=6379 +``` + +**セキュリティ注意**: +- `.env` ファイルは**Git管理しない**こと(`.gitignore` に追加済み) +- `PROXY_AUTH_TOKEN` は推測困難な長いランダム文字列を使用 + +--- + +## 📂 ステップ3: Docker Composeでコンテナ起動 + +### 3-1. SSH接続でSynologyにログイン + +```bash +ssh admin@100.76.7.3 +``` + +### 3-2. プロジェクトディレクトリに移動 + +```bash +cd /volume1/docker/ponshu_proxy +``` + +### 3-3. Docker Composeで起動 + +```bash +sudo docker-compose up -d +``` + +**実行内容**: +- `ponshu-redis`: Redisコンテナを起動(ポート6379、永続化有効) +- `ponshu-proxy-server`: Proxy Serverコンテナを起動(ポート8080、Redis接続) + +### 3-4. 起動確認 + +```bash +sudo docker-compose ps +``` + +**期待される出力**: +``` +NAME IMAGE STATUS +ponshu-redis redis:7-alpine Up (healthy) +ponshu-proxy-server ponshu_proxy-proxy Up (healthy) +``` + +--- + +## ✅ ステップ4: 動作確認 + +### 4-1. ヘルスチェック + +```bash +curl http://100.76.7.3:8080/health +``` + +**期待される出力**: `OK` + +### 4-2. Redis接続確認 + +```bash +# Redisコンテナに接続 +sudo docker exec -it ponshu-redis redis-cli + +# Redis内でコマンド実行 +127.0.0.1:6379> PING +PONG + +127.0.0.1:6379> KEYS * +(empty array) # 初回起動時は空 + +127.0.0.1:6379> exit +``` + +### 4-3. レート制限テスト(Flutter アプリから実行) + +Flutter アプリ(`lib/secrets.local.dart`)で以下を設定: + +```dart +static const String aiProxyAnalyzeUrl = 'http://100.76.7.3:8080/analyze'; +static const bool useProxy = true; +static const String proxyAuthToken = 'your-secure-token-here'; // .envと同じ値 +``` + +アプリから日本酒ラベル解析を実行し、成功することを確認。 + +### 4-4. Redis内のデータ確認 + +```bash +sudo docker exec -it ponshu-redis redis-cli + +# 使用状況を確認(device_idは実際のデバイスIDに置き換え) +127.0.0.1:6379> KEYS usage:* +1) "usage:device-123:2026-02-21" + +127.0.0.1:6379> GET "usage:device-123:2026-02-21" +"1" # 1回解析済み + +127.0.0.1:6379> TTL "usage:device-123:2026-02-21" +(integer) 43200 # 残り12時間(秒単位) +``` + +**重要**: TTL(有効期限)が設定されていることを確認してください。これにより、翌日0時以降に自動的にリセットされます。 + +--- + +## 🔄 ステップ5: コンテナ再起動テスト(永続化確認) + +### 5-1. コンテナ再起動 + +```bash +sudo docker-compose restart +``` + +### 5-2. 再起動後のデータ確認 + +```bash +sudo docker exec -it ponshu-redis redis-cli + +127.0.0.1:6379> GET "usage:device-123:2026-02-21" +"1" # 再起動前のカウントが維持されている +``` + +**成功条件**: 再起動後もカウントが保持されていること。 + +--- + +## 📊 運用上の注意点 + +### ログの確認方法 + +```bash +# Proxy Serverのログ +sudo docker-compose logs -f proxy + +# Redisのログ +sudo docker-compose logs -f redis +``` + +### コンテナの停止・削除 + +```bash +# 停止(データは保持) +sudo docker-compose stop + +# 完全削除(データも削除される) +sudo docker-compose down -v +``` + +**警告**: `docker-compose down -v` を実行すると、Redisのデータボリュームも削除されます。 + +### Redis データのバックアップ + +```bash +# Redisのデータをダンプ +sudo docker exec ponshu-redis redis-cli SAVE + +# ダンプファイルをコピー +sudo docker cp ponshu-redis:/data/dump.rdb /volume1/backups/redis-backup-$(date +%Y%m%d).rdb +``` + +--- + +## 🚀 Dokploy移行への準備 + +この構成は**Dokploy環境にそのまま移行可能**です。 + +### Dokploy移行時の変更点 + +1. **Gitea連携**: Gitea pushで自動デプロイ +2. **環境変数管理**: `.env` ファイルの代わりにDokployのWeb UIで設定 +3. **ドメイン設定**: `ponshu-proxy.local` 等のカスタムドメイン自動割り当て +4. **スケーリング**: Docker Swarmで複数ノードに分散可能 + +**移行時期**: Week 2(日本酒アプリとダッシュボードを同時にDokployへ移行) + +--- + +## 🆘 トラブルシューティング + +### Q1: Proxy Serverが起動しない(`Redis connection failed`) + +**原因**: Redisコンテナが起動していない、またはネットワーク設定が誤っている。 + +**解決策**: +```bash +# Redisの状態確認 +sudo docker-compose ps + +# Redisが "unhealthy" の場合、ログを確認 +sudo docker-compose logs redis +``` + +### Q2: `docker-compose` コマンドが見つからない + +**原因**: Synology DSMのバージョンによっては `docker compose`(スペース)を使用。 + +**解決策**: +```bash +sudo docker compose up -d # ハイフンなし +``` + +### Q3: Flutter アプリから接続できない(認証エラー) + +**原因**: `PROXY_AUTH_TOKEN` が `.env` と `secrets.local.dart` で一致していない。 + +**解決策**: +1. `.env` の `PROXY_AUTH_TOKEN` を確認 +2. Flutter の `lib/secrets.local.dart` の `proxyAuthToken` を同じ値に設定 +3. Flutter アプリを再起動 + +--- + +## ✅ 完了チェックリスト + +- [ ] 既存コンテナを停止・削除 +- [ ] 新しいファイル(`docker-compose.yml`, 更新版 `server.js`, `package.json`)をアップロード +- [ ] `.env` ファイルを作成(APIキー、認証トークン設定) +- [ ] `docker-compose up -d` で起動成功 +- [ ] `curl http://100.76.7.3:8080/health` で `OK` 返却 +- [ ] Redis内でデータが保存されることを確認(`KEYS usage:*`) +- [ ] コンテナ再起動後もデータが維持されることを確認 +- [ ] Flutter アプリから解析が成功することを確認 + +--- + +**移行完了後は、この手順書を `docs/` フォルダに移動してアーカイブしてください。** diff --git a/tools/proxy/docker-compose.yml b/tools/proxy/docker-compose.yml new file mode 100644 index 0000000..7ac7a97 --- /dev/null +++ b/tools/proxy/docker-compose.yml @@ -0,0 +1,53 @@ +version: '3.8' + +services: + # Redis: Rate Limit永続化用 + redis: + image: redis:7-alpine + container_name: ponshu-redis + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis-data:/data + command: redis-server --appendonly yes # AOF永続化有効化 + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 10s + timeout: 3s + retries: 3 + networks: + - ponshu-network + + # Proxy Server: Gemini API プロキシ + proxy: + build: . + container_name: ponshu-proxy-server + restart: unless-stopped + ports: + - "8080:8080" + environment: + - PORT=8080 + - GEMINI_API_KEY=${GEMINI_API_KEY} + - PROXY_AUTH_TOKEN=${PROXY_AUTH_TOKEN} + - DAILY_LIMIT=${DAILY_LIMIT:-50} + - REDIS_HOST=redis + - REDIS_PORT=6379 + depends_on: + redis: + condition: service_healthy + healthcheck: + test: [ "CMD-SHELL", "node -e \"require('http').get('http://localhost:8080/health', (r) => { if (r.statusCode !== 200) process.exit(1); }).on('error', () => process.exit(1))\"" ] + interval: 30s + timeout: 10s + retries: 3 + networks: + - ponshu-network + +volumes: + redis-data: + driver: local + +networks: + ponshu-network: + driver: bridge diff --git a/tools/proxy/package.json b/tools/proxy/package.json index 54d8e0f..8d37ee3 100644 --- a/tools/proxy/package.json +++ b/tools/proxy/package.json @@ -11,6 +11,7 @@ "body-parser": "^1.20.2", "cors": "^2.8.5", "dotenv": "^16.3.1", - "@google/generative-ai": "^0.21.0" + "@google/generative-ai": "^0.21.0", + "redis": "^4.7.0" } } \ No newline at end of file diff --git a/tools/proxy/server.js b/tools/proxy/server.js index 4bef792..b759559 100644 --- a/tools/proxy/server.js +++ b/tools/proxy/server.js @@ -1,6 +1,7 @@ const express = require('express'); const bodyParser = require('body-parser'); const { GoogleGenerativeAI } = require('@google/generative-ai'); +const { createClient } = require('redis'); require('dotenv').config(); const app = express(); @@ -8,9 +9,36 @@ const PORT = process.env.PORT || 8080; const API_KEY = process.env.GEMINI_API_KEY; const AUTH_TOKEN = process.env.PROXY_AUTH_TOKEN; -// Rate Limiting (Simple In-Memory) +// Rate Limiting Configuration const DAILY_LIMIT = parseInt(process.env.DAILY_LIMIT || '50', 10); -const usageStore = {}; // { deviceId: { date: 'YYYY-MM-DD', count: 0 } } +const REDIS_HOST = process.env.REDIS_HOST || 'localhost'; +const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10); + +// Redis Client Setup +const redisClient = createClient({ + socket: { + host: REDIS_HOST, + port: REDIS_PORT + } +}); + +redisClient.on('error', (err) => { + console.error('[Redis] Connection Error:', err); +}); + +redisClient.on('connect', () => { + console.log(`[Redis] Connected to ${REDIS_HOST}:${REDIS_PORT}`); +}); + +// Initialize Redis connection +(async () => { + try { + await redisClient.connect(); + } catch (err) { + console.error('[Redis] Failed to connect:', err); + process.exit(1); // Exit if Redis is unavailable + } +})(); // Authentication Middleware (skip for /health) function authMiddleware(req, res, next) { @@ -42,37 +70,74 @@ app.use((req, res, next) => { }); app.use(bodyParser.json({ limit: '10mb' })); -// Gemini Client +// Gemini Client with JSON response configuration const genAI = new GoogleGenerativeAI(API_KEY); -const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); // Flutter側(gemini_service.dart)と統一 +const model = genAI.getGenerativeModel({ + model: "gemini-2.5-flash", // Flutter側(gemini_service.dart)と統一 + generationConfig: { + responseMimeType: "application/json", // Force JSON-only output + temperature: 0.2, // チャート一貫性向上のため(Flutter側と統一) + } +}); // Helper: Get Today's Date String (YYYY-MM-DD) function getTodayString() { return new Date().toISOString().split('T')[0]; } -// Helper: Check & Update Rate Limit -function checkRateLimit(deviceId) { +// Helper: Check & Update Rate Limit (Redis-based) +async function checkRateLimit(deviceId) { const today = getTodayString(); + const redisKey = `usage:${deviceId}:${today}`; - if (!usageStore[deviceId]) { - usageStore[deviceId] = { date: today, count: 0 }; + try { + // Get current usage count + const currentCount = await redisClient.get(redisKey); + const count = currentCount ? parseInt(currentCount, 10) : 0; + const remaining = DAILY_LIMIT - count; + + return { + allowed: remaining > 0, + current: count, + limit: DAILY_LIMIT, + remaining: remaining, + redisKey: redisKey + }; + } catch (err) { + console.error('[Redis] Error checking rate limit:', err); + // Fallback: deny request if Redis is down + return { + allowed: false, + current: 0, + limit: DAILY_LIMIT, + remaining: 0, + error: 'Rate limit check failed' + }; } +} - // Reset if new day - if (usageStore[deviceId].date !== today) { - usageStore[deviceId] = { date: today, count: 0 }; +// Helper: Increment Usage Count (Redis-based) +async function incrementUsage(deviceId) { + const today = getTodayString(); + const redisKey = `usage:${deviceId}:${today}`; + + try { + // Increment count + const newCount = await redisClient.incr(redisKey); + + // Set expiration to end of day (86400 seconds = 24 hours) + const now = new Date(); + const midnight = new Date(now); + midnight.setHours(24, 0, 0, 0); + const secondsUntilMidnight = Math.floor((midnight - now) / 1000); + + await redisClient.expire(redisKey, secondsUntilMidnight); + + return newCount; + } catch (err) { + console.error('[Redis] Error incrementing usage:', err); + throw err; } - - const currentUsage = usageStore[deviceId]; - const remaining = DAILY_LIMIT - currentUsage.count; - - return { - allowed: remaining > 0, - current: currentUsage.count, - limit: DAILY_LIMIT, - remaining: remaining - }; } // API Endpoint (authentication enforced by global middleware) @@ -83,20 +148,20 @@ app.post('/analyze', async (req, res) => { return res.status(400).json({ success: false, error: 'Device ID is required' }); } - // 1. Check Rate Limit - const limitStatus = checkRateLimit(device_id); - if (!limitStatus.allowed) { - console.log(`[Limit Reached] Device: ${device_id} (${limitStatus.current}/${limitStatus.limit})`); - return res.status(429).json({ - success: false, - error: 'Daily limit reached', - usage: { today: limitStatus.current, limit: limitStatus.limit } - }); - } - - console.log(`[Request] Device: ${device_id} | Images: ${images ? images.length : 0}`); - try { + // 1. Check Rate Limit (Redis-based) + const limitStatus = await checkRateLimit(device_id); + if (!limitStatus.allowed) { + console.log(`[Limit Reached] Device: ${device_id} (${limitStatus.current}/${limitStatus.limit})`); + return res.status(429).json({ + success: false, + error: limitStatus.error || 'Daily limit reached', + usage: { today: limitStatus.current, limit: limitStatus.limit } + }); + } + + console.log(`[Request] Device: ${device_id} | Images: ${images ? images.length : 0} | Current: ${limitStatus.current}/${limitStatus.limit}`); + // 2. Prepare Gemini Request // Base64 images to GenerativeContentBlob const imageParts = (images || []).map(base64 => ({ @@ -111,32 +176,41 @@ app.post('/analyze', async (req, res) => { const text = response.text(); // 3. Parse JSON from Markdown (e.g. ```json ... ```) + console.log(`[Debug] Gemini raw response (first 200 chars): ${text.substring(0, 200)}`); + const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/) || text.match(/```([\s\S]*?)```/); let jsonData; if (jsonMatch) { + console.log('[Debug] Found JSON in code block'); jsonData = JSON.parse(jsonMatch[1]); } else { // Try parsing raw text if no code blocks - jsonData = JSON.parse(text); + console.log('[Debug] Attempting to parse raw text as JSON'); + try { + jsonData = JSON.parse(text); + } catch (parseError) { + console.error('[Error] JSON parse failed. Raw response:', text); + throw new Error(`Failed to parse Gemini response as JSON. Response starts with: ${text.substring(0, 100)}`); + } } - // 4. Update Usage - usageStore[device_id].count++; - console.log(`[Success] Device: ${device_id} | Count: ${usageStore[device_id].count}/${DAILY_LIMIT}`); + // 4. Increment Usage (Redis-based) + const newCount = await incrementUsage(device_id); + console.log(`[Success] Device: ${device_id} | New Count: ${newCount}/${DAILY_LIMIT}`); // 5. Send Response res.json({ success: true, data: jsonData, usage: { - today: usageStore[device_id].count, + today: newCount, limit: DAILY_LIMIT } }); } catch (error) { - console.error('Gemini API Error:', error); + console.error('[Error] Gemini API or Redis Error:', error); res.status(500).json({ success: false, error: error.message || 'Internal Server Error'