Compare commits

...

3 Commits

Author SHA1 Message Date
Ponshu Developer da05455e7c config: Change useProxy default to false for general distribution
Change default behavior from Proxy mode to Direct API mode:
- useProxy defaultValue: true -> false
- General distribution: Users provide their own Gemini API key
- Development with Proxy: Use --dart-define=USE_PROXY=true

Rationale:
- Proxy mode requires Tailscale connection (not suitable for public distribution)
- Direct API mode works anywhere with internet connection
- Each user manages their own API quota

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-22 09:59:32 +09:00
Ponshu Developer 44f88ff04b fix(proxy): Bind express to 0.0.0.0 and update healthcheck 2026-02-21 23:35:36 +09:00
Ponshu Developer 10f772942a feat: Implement Redis persistence for Synology Proxy rate limiting
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 <noreply@anthropic.com>
2026-02-21 19:35:59 +09:00
9 changed files with 689 additions and 165 deletions

View File

@ -1,145 +1,85 @@
# 📋 Ponshu Room Lite - Current TODO List # 📋 Ponshu Room Lite - Current TODO List & Roadmap
**Last Updated**: 2026-01-21 **Last Updated**: 2026-02-21 (v1.0.16)
**Status**: Active Task Tracking **Status**: Active Task Tracking (Based on Claude's Comprehensive Review)
**For**: Multi-AI Collaboration (Claude + Antigravity + Gemini) **For**: Multi-AI Collaboration (Claude + Antigravity + Gemini)
--- ---
## 🔴 CRITICAL: Deferred Tasks (DO NOT FORGET) ## 🔴 High Priority (品質・配布に直結 / 即時対応推奨)
### Dark Mode Visibility Fixes (Phase 1 - INCOMPLETE) - [ ] **H1. ダークモード完全対応** (推定: 6-8時間)
- **対象**: `sake_detail_screen.dart:794` - 9箇所の `Theme.of(context).primaryColor``colorScheme` に置換。コアスクリーンでの視認性問題のため優先。
- [x] ✅ Create Dark Mode guidelines ([DARK_MODE_COLOR_GUIDELINES.md](DARK_MODE_COLOR_GUIDELINES.md)) - [ ] **H2. 大規模ファイルのリファクタリング** (推定: 8-12時間)
- [x] ✅ Redesign app_theme.dart to use Material 3 ColorScheme - `camera_screen.dart` (966行): `CameraControls`, `PhotoPreview`, `GalleryPicker` に分割。
- [x] ✅ Fix guide_screen.dart (section headers to secondary color) - `sake_detail_screen.dart` (794行): 分割済みだが監視継続 (`BasicInfo`, `TasteChart`, `Sakenowa`, `Pricing`)。
- [x] ✅ Fix soul_screen.dart (10+ manual dark mode checks removed) - `menu_pricing_screen.dart` (650行): `PricingInputSection`, `PricingPreview` に分割。
- [ ] ⏸️ **DEFERRED**: Fix [sake_detail_screen.dart](../lib/screens/sake_detail_screen.dart) - [ ] **H3. Synology Proxy永続化対応** (推定: 4-6時間)
- **Why Deferred**: 1500+ lines, recently modified, needs careful review - **現状**: In-Memoryのため、再起動で利用回数クォータがリセットされる。
- **Issues**: 9 instances of `Theme.of(context).primaryColor` usage - **解決策**: Redis導入推奨またはファイルベースの永続化。
- **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
--- ---
## 🟡 Phase 2: Planned Features (READY TO START) ## 🟡 Medium Priority (UX改善・機能追加)
See detailed implementation plan: [PHASE_2_IMPLEMENTATION_PLAN.md](PHASE_2_IMPLEMENTATION_PLAN.md) - [ ] **M1. AI「あわせて飲みたい」機能実装** (推定: 12-16時間)
- **要件**: 履歴分析、未登録銘柄からの推薦文生成。ソムリエタブのプレースホルダーを実稼働させる。
**Summary**: - [ ] **M2. チャート手動編集UIの完成** (推定: 8-10時間)
1. **Help Button Placement** (Pattern C: Hybrid approach) - 6 hours - **内容**: AI生成の5軸チャートをユーザーが微調整できる機能。
2. **AI-Powered "あわせて飲みたい" Recommendations** - 12 hours - [ ] **M3. ヘルプボタンの配置改善** (推定: 6時間)
3. **AI Analysis Info Editing** - 8 hours - **方針**: Pattern C (Hybrid approach) - 各画面の状況に応じて配置。
- [ ] **M4. Pro版機能の実装** (推定: 20-24時間)
**Total Estimated Time**: 26 hours - **要件**: QRスキャン連携、Instagram投稿生成機能、売上アナリティクスダッシュボード等。
--- ---
## 🟢 Phase 3: Technical Debt & Cleanup ## 🟢 Low Priority (技術的負債・長期改善)
### Coach Mark / Tutorial Cleanup - [ ] **L1. debugPrint整理** (推定: 3-4時間)
- [ ] Remove `hasSeenTutorial` from UserProfile model - **対象**: 221箇所23ファイル。`if (kDebugMode)` で囲む(主に `gemini_service.dart`, `analysis_cache_service.dart`)。
- [ ] Remove `hasSeenCoachMarks` from UserProfile model - [ ] **L2. ハードコード色の段階的修正** (推定: 10-15時間)
- [ ] Remove tutorial-related methods from theme_provider.dart - **対象**: 意図的な例外(カメラビューなど)を除くハードコード色。`onboarding_dialog.dart` や `pending_analysis_screen.dart` などから優先。
- [ ] Remove tutorial-related images/assets - [ ] **L3. 真の画像圧縮実装** (推定: 3-4時間)
- **Why**: Tutorial service was deleted due to persistence bugs - **現状**: コピーのみの `ImageCompressionService``flutter_image_compress` などの圧縮処理に置き換え。
- **Priority**: Low (not breaking anything, just cluttering code) - [ ] **L4. Tutorial/CoachMark残存コードの削除** (推定: 1-2時間)
- **Estimated Time**: 1 hour - **対象**: `UserProfile` に存在したチュートリアル系不要フィールドと関連アセットの削除の完全完了。
- [ ] **L5. テストコード導入** (推定: 8-12時間)
### Flutter Analyze Warnings - `SakeItem` JSON変換、`LevelCalculator`、`GeminiService` 等々のカバレッジ向上。
- [ ] Fix 49 warnings (see `flutter analyze` output) - [ ] **L6. UI/UXのさらなる改善**
- 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
--- ---
## 📚 Documentation Status ## 🔮 Future / 長期構想 (Phase 3+)
### ✅ Complete & Up-to-date - [ ] **F1. Firebase同期** (16-20時間): クラウドバックアップ、複数デバイス同期。
- [DARK_MODE_COLOR_GUIDELINES.md](DARK_MODE_COLOR_GUIDELINES.md) - Dark Mode implementation guide - [ ] **F2. Posimai Core Platform構築** (40時間〜): 日本酒・香道アプリの共通基盤化。
- [PROJECT_BACKLOG_MASTER.md](PROJECT_BACKLOG_MASTER.md) - High-level roadmap - [ ] **F3. 多言語対応** (20時間): 英語・中国語対応ARBファイル利用
- [UI_UX_BACKLOG.md](UI_UX_BACKLOG.md) - UI/UX improvement tracking - [ ] **F4. 酒蔵マップ実データ統合** (12時間): プレースホルダーから実データへの連携、GPS連動等。
- [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)
--- ---
## 🎯 Recommended Next Steps ## 🏗️ Synology環境の堅牢化 (運用サーバー対応)
For immediate work, choose ONE of the following: - [ ] **S1. Proxy Serverのレート制限永続化** (Critical / =H3)
- Container Managerで Redis コンテナを立ち上げ、Node.jsプロキシに接続して利用履歴を永続化。
### Option A: Complete Dark Mode Fixes - [ ] **S2. バックアップ戦略の確立** (Critical)
1. Fix sake_detail_screen.dart (~2-3 hours) - Hyper Backupでのプロジェクト全体の外部保存、Dokploy / Giteaの定期ボリュームバックアップの設定。
2. Fix shop_settings_screen.dart (~1 hour) - [ ] **S3. SSL/TLS対応**
3. Mark Dark Mode project as complete - Tailscale Funnel や Synology Application Portal (リバースプロキシ) の構築。
- [ ] **S4. 監視・アラート設定**
### Option B: Start Phase 2 Features - Uptime Kuma などを導入し、ダウン時に通知を飛ばす体制づくり。
1. Implement Help Button Placement (~6 hours) - [ ] **S5-10. インフラのさらなる拡張** (Important/Nice to Have)
2. Creates user value immediately - CI/CD構築Gitea Actions, Dokploy連携等、共有基盤化、Webhook通知など。
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.
--- ---
## 🚨 Known Issues to Avoid ## 📅 推奨作業順序 (今後2週間)
1. **Image Path Repair Service** - Works correctly after Day 4 bug fix 1. **品質安定化 (Week 1)**
2. **Backup/Restore** - Functional, tested successfully - Day 1-2: Synology Proxy永続化 (S1 / H3)
3. **Tutorial Service** - Deleted, do not attempt to restore - Day 3-4: ダークモード完全対応 (H1)
4. **Dark Mode** - Fixed for core screens, remaining issues documented above - Day 5: バックアップ戦略確立 (S2)
2. **機能拡張 or インフラ強化 (Week 2)**
--- - *Option A (機能優先)*: 推薦機能(M1) チャート微調整(M2)
- *Option B (インフラ優先)*: Dokploy設定 CI/CDテスト 監視設定
## 📞 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.*

View File

@ -23,10 +23,11 @@ class Secrets {
/// AI Mode: Proxy(Home) vs Direct(Cloud) /// AI Mode: Proxy(Home) vs Direct(Cloud)
/// If false, connects directly to Google Gemini API (Works anywhere). /// If false, connects directly to Google Gemini API (Works anywhere).
/// Release build: --dart-define=USE_PROXY=true /// Development (with Tailscale): --dart-define=USE_PROXY=true
/// General distribution: false (each user provides their own Gemini API key)
static const bool useProxy = bool.fromEnvironment( static const bool useProxy = bool.fromEnvironment(
'USE_PROXY', 'USE_PROXY',
defaultValue: false, defaultValue: false, // : Direct APIAPIキー設定
); );

13
tools/proxy/.env.example Normal file
View File

@ -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

12
tools/proxy/.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
# Environment variables (contains secrets)
.env
# Node modules
node_modules/
# Logs
*.log
npm-debug.log*
# Docker volumes
data/

140
tools/proxy/README.md Normal file
View File

@ -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 <token>
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

View File

@ -0,0 +1,290 @@
# Synology Proxy Server - Redis永続化移行ガイド
## 📋 概要
このガイドでは、Synology NAS上のProxy Serverを**In-Memory方式からRedis永続化方式**に移行する手順を説明します。
### 移行の目的
- **永続化**: Dockerコンテナ再起動後もレート制限カウントが維持される
- **Dokploy対応**: 将来のDokploy導入時にそのまま移行可能な構成
- **複数アプリ対応**: ダッシュボードWebシステム等、他プロジェクトでもRedisを共有可能
---
## 🛠️ 前提条件
- Synology NASContainer 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/` フォルダに移動してアーカイブしてください。**

View File

@ -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

View File

@ -11,6 +11,7 @@
"body-parser": "^1.20.2", "body-parser": "^1.20.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"@google/generative-ai": "^0.21.0" "@google/generative-ai": "^0.21.0",
"redis": "^4.7.0"
} }
} }

View File

@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const { GoogleGenerativeAI } = require('@google/generative-ai'); const { GoogleGenerativeAI } = require('@google/generative-ai');
const { createClient } = require('redis');
require('dotenv').config(); require('dotenv').config();
const app = express(); const app = express();
@ -8,9 +9,36 @@ const PORT = process.env.PORT || 8080;
const API_KEY = process.env.GEMINI_API_KEY; const API_KEY = process.env.GEMINI_API_KEY;
const AUTH_TOKEN = process.env.PROXY_AUTH_TOKEN; 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 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) // Authentication Middleware (skip for /health)
function authMiddleware(req, res, next) { function authMiddleware(req, res, next) {
@ -35,44 +63,81 @@ function authMiddleware(req, res, next) {
next(); next();
} }
// Global middleware: Auth first (skip /health), then body parser // Global middleware: Body parser first, then auth (skip /health)
app.use(bodyParser.json({ limit: '10mb' }));
app.use((req, res, next) => { app.use((req, res, next) => {
if (req.path === '/health') return next(); if (req.path === '/health') return next();
authMiddleware(req, res, next); authMiddleware(req, res, next);
}); });
app.use(bodyParser.json({ limit: '10mb' }));
// Gemini Client // Gemini Client with JSON response configuration
const genAI = new GoogleGenerativeAI(API_KEY); 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) // Helper: Get Today's Date String (YYYY-MM-DD)
function getTodayString() { function getTodayString() {
return new Date().toISOString().split('T')[0]; return new Date().toISOString().split('T')[0];
} }
// Helper: Check & Update Rate Limit // Helper: Check & Update Rate Limit (Redis-based)
function checkRateLimit(deviceId) { async function checkRateLimit(deviceId) {
const today = getTodayString(); const today = getTodayString();
const redisKey = `usage:${deviceId}:${today}`;
if (!usageStore[deviceId]) { try {
usageStore[deviceId] = { date: today, count: 0 }; // 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 // Helper: Increment Usage Count (Redis-based)
if (usageStore[deviceId].date !== today) { async function incrementUsage(deviceId) {
usageStore[deviceId] = { date: today, count: 0 }; 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) // 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' }); 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 { 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 // 2. Prepare Gemini Request
// Base64 images to GenerativeContentBlob // Base64 images to GenerativeContentBlob
const imageParts = (images || []).map(base64 => ({ const imageParts = (images || []).map(base64 => ({
@ -111,32 +176,41 @@ app.post('/analyze', async (req, res) => {
const text = response.text(); const text = response.text();
// 3. Parse JSON from Markdown (e.g. ```json ... ```) // 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]*?)```/); const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/) || text.match(/```([\s\S]*?)```/);
let jsonData; let jsonData;
if (jsonMatch) { if (jsonMatch) {
console.log('[Debug] Found JSON in code block');
jsonData = JSON.parse(jsonMatch[1]); jsonData = JSON.parse(jsonMatch[1]);
} else { } else {
// Try parsing raw text if no code blocks // 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 // 4. Increment Usage (Redis-based)
usageStore[device_id].count++; const newCount = await incrementUsage(device_id);
console.log(`[Success] Device: ${device_id} | Count: ${usageStore[device_id].count}/${DAILY_LIMIT}`); console.log(`[Success] Device: ${device_id} | New Count: ${newCount}/${DAILY_LIMIT}`);
// 5. Send Response // 5. Send Response
res.json({ res.json({
success: true, success: true,
data: jsonData, data: jsonData,
usage: { usage: {
today: usageStore[device_id].count, today: newCount,
limit: DAILY_LIMIT limit: DAILY_LIMIT
} }
}); });
} catch (error) { } catch (error) {
console.error('Gemini API Error:', error); console.error('[Error] Gemini API or Redis Error:', error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: error.message || 'Internal Server Error' error: error.message || 'Internal Server Error'
@ -150,7 +224,7 @@ app.get('/health', (req, res) => {
}); });
// Start Server // Start Server
app.listen(PORT, () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`Proxy Server running on port ${PORT}`); console.log(`Proxy Server running on port ${PORT}`);
if (!API_KEY) console.error('WARNING: GEMINI_API_KEY is not set!'); if (!API_KEY) console.error('WARNING: GEMINI_API_KEY is not set!');
if (!AUTH_TOKEN) console.error('WARNING: PROXY_AUTH_TOKEN is not set! Authentication is disabled.'); if (!AUTH_TOKEN) console.error('WARNING: PROXY_AUTH_TOKEN is not set! Authentication is disabled.');