diff --git a/.github/workflows/ios_build.yml b/.github/workflows/ios_build.yml new file mode 100644 index 0000000..e5b3761 --- /dev/null +++ b/.github/workflows/ios_build.yml @@ -0,0 +1,43 @@ +name: iOS Build & TestFlight + +on: + push: + tags: + - 'v*' # 例: git tag v1.0.0 をプッシュした時に実行 + +jobs: + build: + name: Build & Deploy to TestFlight + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.29.x' + channel: 'stable' + + - name: Install dependencies + run: flutter pub get + + # iOSビルド (証明書不要の No Code Sign オプション) + # 実際の署名はAppStore Connectへのアップロード時に行われる + - name: Build iOS + run: | + flutter build ios --release --no-codesign \ + --dart-define=GEMINI_API_KEY=dist-build-key \ + --dart-define=AI_PROXY_URL=${{ secrets.VPS_PROXY_URL }} \ + --dart-define=USE_PROXY=true + + # TestFlight へのアップロード + # App Store Connect API Key を GitHub Secrets に設定する必要あり + - name: Upload to TestFlight + uses: apple-actions/upload-testflight-build@v1 + with: + app-path: 'build/ios/iphoneos/Runner.app' + issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }} + api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }} + api-private-key: ${{ secrets.APPSTORE_API_PRIVATE_KEY }} diff --git a/lib/main.dart b/lib/main.dart index 64b46cc..e79db1e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'models/user_profile.dart'; import 'models/menu_settings.dart'; import 'providers/theme_provider.dart'; import 'screens/main_screen.dart'; +import 'screens/license_screen.dart'; import 'services/migration_service.dart'; /// Pro版かLite版かを判定するビルド時フラグ @@ -111,6 +112,9 @@ class MyApp extends ConsumerWidget { navigatorObservers: [routeObserver], home: const MainScreen(), + routes: { + '/upgrade': (context) => const LicenseScreen(), + }, ); } } diff --git a/lib/providers/license_provider.dart b/lib/providers/license_provider.dart new file mode 100644 index 0000000..7a03398 --- /dev/null +++ b/lib/providers/license_provider.dart @@ -0,0 +1,19 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../services/license_service.dart'; + +/// ライセンス状態の非同期プロバイダー +/// +/// アプリ起動時に一度だけVPSに問い合わせ、結果をキャッシュする。 +/// 手動更新は [licenseStatusProvider].invalidate() を呼ぶ。 +final licenseStatusProvider = FutureProvider((ref) async { + return LicenseService.checkStatus(); +}); + +/// Pro版かどうか(ナビゲーション・機能解放の分岐に使う) +final isProProvider = Provider((ref) { + final statusAsync = ref.watch(licenseStatusProvider); + return statusAsync.maybeWhen( + data: (status) => status == LicenseStatus.pro, + orElse: () => false, + ); +}); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 1fb79b4..b486e46 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -24,6 +24,7 @@ import 'package:lucide_icons/lucide_icons.dart'; import '../widgets/prefecture_filter_sheet.dart'; import '../widgets/pending_analysis_banner.dart'; import '../widgets/common/error_retry_widget.dart'; +; // CR-006: NotifierProviderでオンボーディングチェック状態を管理(グローバル変数を削除) class HasCheckedOnboardingNotifier extends Notifier { diff --git a/lib/screens/license_screen.dart b/lib/screens/license_screen.dart new file mode 100644 index 0000000..4671f85 --- /dev/null +++ b/lib/screens/license_screen.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers/license_provider.dart'; +import '../services/license_service.dart'; +import '../theme/app_colors.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class LicenseScreen extends ConsumerStatefulWidget { + const LicenseScreen({super.key}); + + @override + ConsumerState createState() => _LicenseScreenState(); +} + +class _LicenseScreenState extends ConsumerState { + final _keyController = TextEditingController(); + bool _isLoading = false; + String? _errorMessage; + + @override + void dispose() { + _keyController.dispose(); + super.dispose(); + } + + Future _activate() async { + final key = _keyController.text.trim(); + if (key.isEmpty) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + final result = await LicenseService.activate(key); + + if (mounted) { + if (result.success) { + ref.invalidate(licenseStatusProvider); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Pro版ライセンスを有効化しました'), + backgroundColor: Theme.of(context).extension()!.success, + ), + ); + Navigator.of(context).pop(); + } else { + setState(() { + _errorMessage = result.message; + _isLoading = false; + }); + } + } + } + + void _openStore() async { + const storeUrl = 'https://posimai-store.soar-enrich.com'; // TODO: 実際のストアURL + final uri = Uri.parse(storeUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).extension()!; + final isPro = ref.watch(isProProvider); + + return Scaffold( + backgroundColor: colors.brandSurface, + appBar: AppBar( + title: Text( + 'ライセンスの有効化', + style: TextStyle(color: colors.textPrimary, fontWeight: FontWeight.bold), + ), + backgroundColor: colors.brandSurface, + elevation: 0, + iconTheme: IconThemeData(color: colors.textPrimary), + ), + body: SafeArea( + child: isPro ? _buildProState(colors) : _buildActivationForm(colors), + ), + ); + } + + Widget _buildProState(AppColors colors) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.verified, size: 64, color: colors.success), + const SizedBox(height: 24), + Text( + 'Pro版ライセンス有効', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: colors.textPrimary, + ), + ), + const SizedBox(height: 16), + Text( + 'AI解析を無制限にご利用いただけます。\nご購入ありがとうございました。', + textAlign: TextAlign.center, + style: TextStyle(color: colors.textSecondary, height: 1.5), + ), + ], + ), + ), + ); + } + + Widget _buildActivationForm(AppColors colors) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon(Icons.key, size: 64, color: colors.brandPrimary), + const SizedBox(height: 24), + Text( + 'ご購入ありがとうございます。', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: colors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + 'メールで受け取った「PONSHU-」から始まる\nライセンスキーを入力してください。', + textAlign: TextAlign.center, + style: TextStyle(color: colors.textSecondary, height: 1.5), + ), + const SizedBox(height: 32), + TextField( + controller: _keyController, + decoration: InputDecoration( + hintText: 'PONSHU-XXXX-XXXX-XXXX', + filled: true, + fillColor: colors.surfaceSubtle, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colors.divider), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colors.divider), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colors.brandPrimary, width: 2), + ), + prefixIcon: Icon(Icons.vpn_key_outlined, color: colors.iconSubtle), + ), + textCapitalization: TextCapitalization.characters, + onSubmitted: (_) => _activate(), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colors.error.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: colors.error, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle(color: colors.error, fontSize: 13), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 24), + ElevatedButton( + onPressed: _isLoading ? null : _activate, + style: ElevatedButton.styleFrom( + backgroundColor: colors.brandPrimary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Text( + '有効化する', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 48), + Divider(color: colors.divider), + const SizedBox(height: 24), + Text( + 'ライセンスをお持ちでない方', + textAlign: TextAlign.center, + style: TextStyle(color: colors.textSecondary, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + OutlinedButton( + onPressed: _openStore, + style: OutlinedButton.styleFrom( + foregroundColor: colors.brandPrimary, + side: BorderSide(color: colors.brandPrimary), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.shopping_cart_outlined, size: 20), + SizedBox(width: 8), + Text('ストアで購入する', style: TextStyle(fontWeight: FontWeight.bold)), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 2e4d2a0..d239d85 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons/lucide_icons.dart'; -import '../main.dart'; // Import isProVersion flag +import '../main.dart'; // Import isBusinessApp flag +import '../providers/license_provider.dart'; import '../providers/theme_provider.dart'; // Access userProfileProvider import '../providers/navigation_provider.dart'; // Track current tab index import '../utils/translations.dart'; // Translation helper @@ -100,6 +101,13 @@ class _MainScreenState extends ConsumerState { onPressed: () => Navigator.of(context).pop(), child: const Text('閉じる'), ), + FilledButton( + onPressed: () { + Navigator.of(context).pop(); + Navigator.of(context).pushNamed('/upgrade'); + }, + child: const Text('ライセンスを有効化'), + ), ], ), ); @@ -127,53 +135,53 @@ class _MainScreenState extends ConsumerState { }); final userProfile = ref.watch(userProfileProvider); + final isPro = ref.watch(isProProvider); // isBusinessApp=false(消費者向けビルド)では店舗モードを完全に無効化 final isBusiness = isBusinessApp && userProfile.isBusinessMode; final t = Translations(userProfile.locale); // Translation helper // Define Screens for each mode - // Lite版のPro限定タブは表示されないようにダミー画面を配置 - // (タップ時にダイアログで対応するため、画面遷移は発生しない) + // Pro版でないタブはダミー画面を配置(タップ時にダイアログで対応) final List screens = isBusiness ? [ const HomeScreen(), // Inventory Management - isProVersion ? const InstaSupportScreen() : const HomeScreen(), // Instagram Support (Pro only) - isProVersion ? const AnalyticsScreen() : const HomeScreen(), // Analytics (Pro only) + isPro ? const InstaSupportScreen() : const HomeScreen(), // Instagram Support (Pro only) + isPro ? const AnalyticsScreen() : const HomeScreen(), // Analytics (Pro only) const ShopSettingsScreen(), // Shop Settings ] : [ const HomeScreen(), // My Sake List - isProVersion ? const ScanARScreen() : const HomeScreen(), // QR Scan (Pro only) + isPro ? const ScanARScreen() : const HomeScreen(), // QR Scan (Pro only) const SommelierScreen(), // Sommelier const BreweryMapScreen(), // Map const SoulScreen(), // MyPage/Settings ]; // Define Navigation Items (with translation) - // Lite版では王冠バッジを表示 + // Pro版でない場合は王冠バッジを表示 final List destinations = isBusiness ? [ NavigationDestination( - icon: const Padding(padding: EdgeInsets.only(bottom: 2), child: Text('🍶', style: TextStyle(fontSize: 22))), + icon: const Padding(padding: EdgeInsets.only(bottom: 2), child: Icon(LucideIcons.home)), label: t['home'], ), NavigationDestination( - icon: isProVersion ? const Icon(LucideIcons.instagram) : const _IconWithCrownBadge(icon: LucideIcons.instagram), + icon: isPro ? const Icon(LucideIcons.instagram) : const _IconWithCrownBadge(icon: LucideIcons.instagram), label: t['promo'], ), NavigationDestination( - icon: isProVersion ? const Icon(LucideIcons.barChart) : const _IconWithCrownBadge(icon: LucideIcons.barChart), + icon: isPro ? const Icon(LucideIcons.barChart) : const _IconWithCrownBadge(icon: LucideIcons.barChart), label: t['analytics'], ), NavigationDestination(icon: const Icon(LucideIcons.store), label: t['shop']), ] : [ NavigationDestination( - icon: const Padding(padding: EdgeInsets.only(bottom: 2), child: Text('🍶', style: TextStyle(fontSize: 22))), + icon: const Padding(padding: EdgeInsets.only(bottom: 2), child: Icon(LucideIcons.home)), label: t['home'], ), NavigationDestination( - icon: isProVersion ? const Icon(LucideIcons.scanLine) : const _IconWithCrownBadge(icon: LucideIcons.scanLine), + icon: isPro ? const Icon(LucideIcons.scanLine) : const _IconWithCrownBadge(icon: LucideIcons.scanLine), label: t['scan'], ), NavigationDestination(icon: const Icon(LucideIcons.sparkles), label: t['sommelier']), @@ -194,8 +202,8 @@ class _MainScreenState extends ConsumerState { bottomNavigationBar: NavigationBar( selectedIndex: _currentIndex, onDestinationSelected: (index) { - // Lite版でPro限定タブをタップした場合はダイアログを表示 - if (!isProVersion) { + // Pro版でない場合にPro限定タブをタップしたらダイアログを表示 + if (!isPro) { if (isBusiness) { // ビジネスモード: Instagram (index 1) と Analytics (index 2) がPro限定 if (index == 1) { diff --git a/lib/services/license_service.dart b/lib/services/license_service.dart new file mode 100644 index 0000000..c8eda33 --- /dev/null +++ b/lib/services/license_service.dart @@ -0,0 +1,157 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'device_service.dart'; +import '../secrets.dart'; + +/// ライセンス状態 +enum LicenseStatus { + /// 無料版(AI解析は1日50回まで利用可能) + free, + + /// Pro版ライセンス有効(Pro機能すべて解放) + pro, + + /// ライセンス無効化済み(不正利用・返金等) + revoked, + + /// オフライン / サーバー疎通不可(キャッシュ利用) + offline, +} + +/// ライセンス管理サービス +/// +/// ## 状態管理の優先順位 +/// 1. オンライン時: VPSに問い合わせた結果を使用 + SharedPreferencesにキャッシュ +/// 2. オフライン時: SharedPreferencesのキャッシュを使用 (Pro状態を維持) +/// +/// ## ライセンスキー形式 +/// PONSHU-XXXX-XXXX-XXXX (16バイト hex, 大文字) +class LicenseService { + static const _prefLicenseKey = 'ponshu_license_key'; + static const _prefCachedStatus = 'ponshu_license_status_cache'; + static const _prefCachedAt = 'ponshu_license_cached_at'; + static const _cacheValidSeconds = 24 * 60 * 60; // 24時間キャッシュ有効 + + // ========== Public API ========== + + /// アプリ起動時に呼ぶ: ライセンス状態を確認して返す + static Future checkStatus() async { + final prefs = await SharedPreferences.getInstance(); + final savedKey = prefs.getString(_prefLicenseKey) ?? ''; + + // ライセンスキーが保存済み → サーバーで検証 + if (savedKey.isNotEmpty) { + try { + final status = await _validateKeyWithServer(savedKey); + await _cacheStatus(prefs, status); + return status; + } catch (e) { + debugPrint('[License] Server unreachable, using cache: $e'); + return _getCachedStatus(prefs); + } + } + + // ライセンスキーなし → 無料版 + return LicenseStatus.free; + } + + /// ライセンスキーをアクティベートする + /// + /// 成功: true + 空メッセージ + /// 失敗: false + エラーメッセージ + static Future<({bool success, String message})> activate(String rawKey) async { + final key = rawKey.trim().toUpperCase(); + + if (!_isValidKeyFormat(key)) { + return (success: false, message: 'キーの形式が正しくありません\n(例: PONSHU-XXXX-XXXX-XXXX)'); + } + + final status = await _validateKeyWithServer(key); + + if (status == LicenseStatus.pro) { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefLicenseKey, key); + await _cacheStatus(prefs, LicenseStatus.pro); + debugPrint('[License] Activated successfully.'); + return (success: true, message: ''); + } + + if (status == LicenseStatus.revoked) { + return (success: false, message: 'このライセンスは無効化されています。\nサポートにお問い合わせください。'); + } + + return (success: false, message: 'ライセンスキーが見つかりません。\nご購入時のメールをご確認ください。'); + } + + /// ライセンスキーがローカルに保存されているか + static Future hasLicenseKey() async { + final prefs = await SharedPreferences.getInstance(); + return (prefs.getString(_prefLicenseKey) ?? '').isNotEmpty; + } + + /// ライセンスをリセット(デバッグ用) + static Future reset() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_prefLicenseKey); + await prefs.remove(_prefCachedStatus); + await prefs.remove(_prefCachedAt); + _cachedTrialInfo = null; + debugPrint('[License] Reset complete.'); + } + + // ========== Private Helpers ========== + + static bool _isValidKeyFormat(String key) { + final regex = RegExp(r'^PONSHU-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$'); + return regex.hasMatch(key); + } + + static Future _validateKeyWithServer(String key) async { + try { + final deviceId = await DeviceService.getDeviceId(); + final response = await http.post( + Uri.parse('${Secrets.aiProxyBaseUrl}/license/validate'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'license_key': key, 'device_id': deviceId}), + ).timeout(const Duration(seconds: 15)); + + final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map; + + if (data['valid'] == true) return LicenseStatus.pro; + if ((data['error'] as String? ?? '').contains('無効化')) return LicenseStatus.revoked; + return LicenseStatus.trialExpired; + + } catch (e) { + debugPrint('[License] Validation network error: $e'); + return LicenseStatus.offline; + } + } + + static Future _cacheStatus(SharedPreferences prefs, LicenseStatus status) async { + await prefs.setString(_prefCachedStatus, status.name); + await prefs.setString(_prefCachedAt, DateTime.now().toIso8601String()); + } + + static LicenseStatus _getCachedStatus(SharedPreferences prefs) { + final cached = prefs.getString(_prefCachedStatus); + final cachedAt = prefs.getString(_prefCachedAt); + + if (cached == null) return LicenseStatus.free; + + // キャッシュが古すぎる場合はfreeにフォールバック + if (cachedAt != null) { + final age = DateTime.now().difference(DateTime.parse(cachedAt)); + if (age.inSeconds > _cacheValidSeconds && cached != LicenseStatus.pro.name) { + return LicenseStatus.free; + } + } + + // Pro キャッシュはオフラインでも維持(購入者を締め出さない) + return LicenseStatus.values.firstWhere( + (s) => s.name == cached, + orElse: () => LicenseStatus.free, + ); + } +} diff --git a/lib/widgets/settings/other_settings_section.dart b/lib/widgets/settings/other_settings_section.dart index 0beb523..2463779 100644 --- a/lib/widgets/settings/other_settings_section.dart +++ b/lib/widgets/settings/other_settings_section.dart @@ -54,6 +54,15 @@ class _OtherSettingsSectionState extends ConsumerState { color: appColors.surfaceSubtle, child: Column( children: [ + ListTile( + leading: Icon(LucideIcons.key, color: appColors.brandPrimary), + title: Text('ライセンスの有効化', style: TextStyle(color: appColors.textPrimary)), + subtitle: Text('Pro版の認証・フリートライアル状態', style: TextStyle(color: appColors.textSecondary)), + trailing: Icon(LucideIcons.chevronRight, color: appColors.iconSubtle), + onTap: () => Navigator.of(context).pushNamed('/upgrade'), + ), + Divider(height: 1, color: appColors.divider), + if (widget.showBusinessMode) ...[ ListTile( leading: Icon(LucideIcons.store, color: appColors.warning), diff --git a/scripts/deploy_android.ps1 b/scripts/deploy_android.ps1 new file mode 100644 index 0000000..cb74c32 --- /dev/null +++ b/scripts/deploy_android.ps1 @@ -0,0 +1,45 @@ +# deploy_android.ps1 +# Firebase App Distribution 用のビルド・配布スクリプト (Ponshu Room Lite) +# +# 前提: +# npm install -g firebase-tools +# firebase login +# +# 引数: +# -ReleaseNotes "アップデート内容" (省略時は "Minor update") + +param ( + [string]$ReleaseNotes = "Minor update" +) + +$ErrorActionPreference = "Stop" + +Write-Host "🍶 Ponshu Room Lite: Android 商用ビルド・配布を開始します" -ForegroundColor Cyan + +# 1. ビルド実行 (APIキーはダミー、VPSへ接続) +Write-Host " -> ビルディング APK..." -ForegroundColor Yellow +flutter build apk --release ` + --dart-define=GEMINI_API_KEY=dist-build-key ` + --dart-define=AI_PROXY_URL=https://(ここにVPSのドメインを入力) ` + --dart-define=USE_PROXY=true ` + --obfuscate ` + --split-debug-info=build\debug-info + +$ApkPath = "build\app\outputs\flutter-apk\app-release.apk" + +if (-Not (Test-Path $ApkPath)) { + Write-Host "❌ エラー: APKが生成されていません ($ApkPath)" -ForegroundColor Red + exit 1 +} + +# 2. Firebase App Distributionへアップロード +# 注意: 初回実行前に Firebase Console で App Distribution を有効にし、アプリIDを調べて以下に入れる +$FirebaseAppId = "1:XXXXXXXXXXXX:android:XXXXXXXXXXXX" # <--- TODO: 書き換える + +Write-Host " -> Firebaseへアップロード中..." -ForegroundColor Yellow +firebase appdistribution:distribute $ApkPath ` + --app $FirebaseAppId ` + --groups "beta-testers" ` + --release-notes $ReleaseNotes + +Write-Host "✅ 配布完了!テスターに通知メールが送信されました。" -ForegroundColor Green diff --git a/tools/proxy/.env.example b/tools/proxy/.env.example index 7949c93..8b8aa21 100644 --- a/tools/proxy/.env.example +++ b/tools/proxy/.env.example @@ -1,13 +1,25 @@ -# Gemini API Key +# ============================================================ +# Ponshu Room Proxy Server — 環境変数設定例 +# ============================================================ + +# [必須] 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認証トークン (生成: openssl rand -hex 32) PROXY_AUTH_TOKEN=your_secure_random_token_here -# Daily request limit per device +# [任意] 1デバイスあたりの日次リクエスト上限 DAILY_LIMIT=50 -# Redis connection settings (default values for Docker Compose) +# Redis接続設定 (Docker Compose デフォルト値) REDIS_HOST=redis REDIS_PORT=6379 + +# サポート連絡先 (ライセンス有効化画面で表示) +APP_SUPPORT_EMAIL=support@posimai.soar-enrich.com + +# ============================================================ +# 注意: Stripe/Resendの処理はメインサーバー (server.js) で行います +# Stripe Webhook → server.js → ライセンスキー生成 → Resendでメール送信 +# このプロキシサーバーはライセンス検証 (/license/validate) のみ担当 +# ============================================================ diff --git a/tools/proxy/server.js b/tools/proxy/server.js index bc0a3dd..586b8a8 100644 --- a/tools/proxy/server.js +++ b/tools/proxy/server.js @@ -2,6 +2,7 @@ const express = require('express'); const bodyParser = require('body-parser'); const { GoogleGenerativeAI } = require('@google/generative-ai'); const { createClient } = require('redis'); +const crypto = require('crypto'); require('dotenv').config(); const app = express(); @@ -14,7 +15,9 @@ const DAILY_LIMIT = parseInt(process.env.DAILY_LIMIT || '50', 10); const REDIS_HOST = process.env.REDIS_HOST || 'localhost'; const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10); -// Redis Client Setup +const APP_SUPPORT_EMAIL = process.env.APP_SUPPORT_EMAIL || 'support@posimai.soar-enrich.com'; + +// ========== Redis Client Setup ========== const redisClient = createClient({ socket: { host: REDIS_HOST, @@ -30,22 +33,30 @@ 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 + process.exit(1); } })(); -// Authentication Middleware (skip for /health) +// ========== Gemini Client ========== +const genAI = new GoogleGenerativeAI(API_KEY); +const model = genAI.getGenerativeModel({ + model: "gemini-2.5-flash", + generationConfig: { + responseMimeType: "application/json", + temperature: 0.2, + } +}); + +// ========== Authentication Middleware ========== function authMiddleware(req, res, next) { if (!AUTH_TOKEN) { - // If no token configured, skip auth (backward compatibility) - console.warn('[Auth] WARNING: PROXY_AUTH_TOKEN is not set. Authentication disabled.'); - return next(); + console.error('[Auth] FATAL: PROXY_AUTH_TOKEN is not set.'); + return res.status(503).json({ success: false, error: 'Server misconfigured: authentication token not set' }); } const authHeader = req.headers['authorization']; @@ -54,7 +65,7 @@ function authMiddleware(req, res, next) { return res.status(401).json({ success: false, error: 'Authentication required' }); } - const token = authHeader.substring(7); // Remove 'Bearer ' prefix + const token = authHeader.substring(7); if (token !== AUTH_TOKEN) { console.log(`[Auth] Rejected: Invalid token`); return res.status(403).json({ success: false, error: 'Invalid authentication token' }); @@ -63,84 +74,55 @@ function authMiddleware(req, res, next) { next(); } -// Global middleware: Body parser first, then auth (skip /health) +// ========== Global Middleware (JSON body parser + auth) ========== app.use(bodyParser.json({ limit: '10mb' })); + app.use((req, res, next) => { - if (req.path === '/health') return next(); + const publicPaths = ['/health', '/license/validate']; + if (publicPaths.includes(req.path)) return next(); authMiddleware(req, res, next); }); -// 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)と統一 - generationConfig: { - responseMimeType: "application/json", // Force JSON-only output - temperature: 0.2, // チャート一貫性向上のため(Flutter側と統一) - } -}); +// ========== Helper Functions ========== -// Helper: Get Today's Date String (YYYY-MM-DD) function getTodayString() { return new Date().toISOString().split('T')[0]; } -// Helper: Check & Update Rate Limit (Redis-based) async function checkRateLimit(deviceId) { - const today = getTodayString(); + const today = getTodayString(); const redisKey = `usage:${deviceId}:${today}`; try { - // Get current usage count const currentCount = await redisClient.get(redisKey); - const count = currentCount ? parseInt(currentCount, 10) : 0; - const remaining = DAILY_LIMIT - count; + 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 - }; + return { allowed: remaining > 0, current: count, limit: DAILY_LIMIT, remaining, 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' - }; + return { allowed: false, current: 0, limit: DAILY_LIMIT, remaining: 0, error: 'Rate limit check failed' }; } } -// Helper: Increment Usage Count (Redis-based) async function incrementUsage(deviceId) { - const today = getTodayString(); + const today = getTodayString(); const redisKey = `usage:${deviceId}:${today}`; - try { - // Increment count - const newCount = await redisClient.incr(redisKey); + 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); + 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); - await redisClient.expire(redisKey, secondsUntilMidnight); - - return newCount; - } catch (err) { - console.error('[Redis] Error incrementing usage:', err); - throw err; - } + return newCount; } -// API Endpoint (authentication enforced by global middleware) +// ========== API Endpoints ========== + +// 既存: AI解析 (認証必須) app.post('/analyze', async (req, res) => { const { device_id, images, prompt } = req.body; @@ -149,7 +131,6 @@ app.post('/analyze', async (req, res) => { } 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})`); @@ -160,33 +141,24 @@ app.post('/analyze', async (req, res) => { }); } - console.log(`[Request] Device: ${device_id} | Images: ${images ? images.length : 0} | Current: ${limitStatus.current}/${limitStatus.limit}`); + console.log(`[Request] Device: ${device_id} | Images: ${images ? images.length : 0} | Count: ${limitStatus.current}/${limitStatus.limit}`); - // 2. Prepare Gemini Request - // Base64 images to GenerativeContentBlob const imageParts = (images || []).map(base64 => ({ - inlineData: { - data: base64, - mimeType: "image/jpeg" - } + inlineData: { data: base64, mimeType: "image/jpeg" } })); - const result = await model.generateContent([prompt, ...imageParts]); + const result = await model.generateContent([prompt, ...imageParts]); const response = await result.response; - const text = response.text(); + 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 - console.log('[Debug] Attempting to parse raw text as JSON'); try { jsonData = JSON.parse(text); } catch (parseError) { @@ -195,38 +167,111 @@ app.post('/analyze', async (req, res) => { } } - // 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: newCount, - limit: DAILY_LIMIT - } + usage: { today: newCount, limit: DAILY_LIMIT } }); } catch (error) { console.error('[Error] Gemini API or Redis Error:', error); - res.status(500).json({ - success: false, - error: error.message || 'Internal Server Error' - }); + res.status(500).json({ success: false, error: error.message || 'Internal Server Error' }); } }); -// Health Check +// 新規: ライセンス検証 (認証不要 — アプリが直接呼ぶ) +app.post('/license/validate', async (req, res) => { + const { license_key, device_id } = req.body; + + if (!license_key || !device_id) { + return res.status(400).json({ valid: false, error: 'Missing parameters' }); + } + + try { + const license = await redisClient.hGetAll(`license:${license_key}`); + + if (!license || Object.keys(license).length === 0) { + return res.json({ valid: false, error: 'ライセンスキーが見つかりません' }); + } + + if (license.status === 'revoked') { + return res.json({ valid: false, error: 'このライセンスは無効化されています。サポートにお問い合わせください。' }); + } + + // 初回アクティベート + if (!license.deviceId || license.deviceId === '') { + await redisClient.hSet(`license:${license_key}`, 'deviceId', device_id); + await redisClient.hSet(`license:${license_key}`, 'activatedAt', new Date().toISOString()); + + console.log(`[License] Activated: ${license_key} → Device: ${device_id.substring(0, 8)}...`); + return res.json({ valid: true, plan: license.plan, activated: true }); + } + + // 既存デバイスの照合 + if (license.deviceId !== device_id) { + console.log(`[License] Device mismatch: ${license_key}`); + return res.json({ + valid: false, + error: '別のデバイスで登録済みです。端末変更の場合はサポートまでご連絡ください。', + supportEmail: APP_SUPPORT_EMAIL, + }); + } + + return res.json({ valid: true, plan: license.plan }); + + } catch (err) { + console.error('[License] Validate error:', err); + res.status(500).json({ valid: false, error: 'サーバーエラーが発生しました' }); + } +}); + +// 管理用 — ライセンス失効 (認証必須) +app.post('/admin/license/revoke', async (req, res) => { + const { license_key } = req.body; + + if (!license_key) { + return res.status(400).json({ success: false, error: 'license_key required' }); + } + + try { + const exists = await redisClient.hExists(`license:${license_key}`, 'status'); + if (!exists) { + return res.json({ success: false, error: 'License not found' }); + } + + await redisClient.hSet(`license:${license_key}`, 'status', 'revoked'); + await redisClient.hSet(`license:${license_key}`, 'revokedAt', new Date().toISOString()); + + console.log(`[Admin] License revoked: ${license_key}`); + return res.json({ success: true, message: `License ${license_key} has been revoked` }); + + } catch (err) { + console.error('[Admin] Revoke error:', err); + res.status(500).json({ success: false, error: 'Server error' }); + } +}); + +// ヘルスチェック app.get('/health', (req, res) => { res.send('OK'); }); -// Start Server +// ========== Server Start ========== +if (!AUTH_TOKEN) { + console.error('[FATAL] PROXY_AUTH_TOKEN is not set. Refusing to start.'); + process.exit(1); +} +if (!API_KEY) { + console.error('[FATAL] GEMINI_API_KEY is not set. Refusing to start.'); + process.exit(1); +} + app.listen(PORT, '0.0.0.0', () => { - console.log(`Proxy Server running on port ${PORT}`); - 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.'); - else console.log('Authentication: Bearer Token enabled'); + console.log(`[Server] Ponshu Room Proxy running on port ${PORT}`); + console.log(`[Server] Auth: Bearer Token enabled`); + console.log(`[Server] Daily Limit: ${DAILY_LIMIT} requests per device`); + console.log(`[Server] License validation: enabled`); });