feat: ライセンス管理システムを追加(Pro機能解放型)
- LicenseService / LicenseProvider / LicenseScreen を追加 - main_screen.dart: isProVersion(ビルド時)→ isProProvider(ランタイム)に変更 ライセンスキー購入後にARスキャン・Analytics・Instagram支援が解放される - home_screen.dart: 不要なTrialStatusBannerを削除(AI解析は常時無制限) - proxy/server.js: トライアル回数制限・Stripe/Resendコードを削除(整理) - proxy/package.json: stripe/resend依存を削除 - .github/workflows/ios_build.yml: iOS CI追加 - scripts/deploy_android.ps1: Android配布スクリプト追加 AI解析(1日50回)はLite版でも制限なし Proライセンス購入でARスキャン・Analytics・Instagram支援を解放 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2b90756417
commit
d47bb201ac
|
|
@ -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 }}
|
||||||
|
|
@ -7,6 +7,7 @@ import 'models/user_profile.dart';
|
||||||
import 'models/menu_settings.dart';
|
import 'models/menu_settings.dart';
|
||||||
import 'providers/theme_provider.dart';
|
import 'providers/theme_provider.dart';
|
||||||
import 'screens/main_screen.dart';
|
import 'screens/main_screen.dart';
|
||||||
|
import 'screens/license_screen.dart';
|
||||||
import 'services/migration_service.dart';
|
import 'services/migration_service.dart';
|
||||||
|
|
||||||
/// Pro版かLite版かを判定するビルド時フラグ
|
/// Pro版かLite版かを判定するビルド時フラグ
|
||||||
|
|
@ -111,6 +112,9 @@ class MyApp extends ConsumerWidget {
|
||||||
|
|
||||||
navigatorObservers: [routeObserver],
|
navigatorObservers: [routeObserver],
|
||||||
home: const MainScreen(),
|
home: const MainScreen(),
|
||||||
|
routes: {
|
||||||
|
'/upgrade': (context) => const LicenseScreen(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../services/license_service.dart';
|
||||||
|
|
||||||
|
/// ライセンス状態の非同期プロバイダー
|
||||||
|
///
|
||||||
|
/// アプリ起動時に一度だけVPSに問い合わせ、結果をキャッシュする。
|
||||||
|
/// 手動更新は [licenseStatusProvider].invalidate() を呼ぶ。
|
||||||
|
final licenseStatusProvider = FutureProvider<LicenseStatus>((ref) async {
|
||||||
|
return LicenseService.checkStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Pro版かどうか(ナビゲーション・機能解放の分岐に使う)
|
||||||
|
final isProProvider = Provider<bool>((ref) {
|
||||||
|
final statusAsync = ref.watch(licenseStatusProvider);
|
||||||
|
return statusAsync.maybeWhen(
|
||||||
|
data: (status) => status == LicenseStatus.pro,
|
||||||
|
orElse: () => false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -24,6 +24,7 @@ import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import '../widgets/prefecture_filter_sheet.dart';
|
import '../widgets/prefecture_filter_sheet.dart';
|
||||||
import '../widgets/pending_analysis_banner.dart';
|
import '../widgets/pending_analysis_banner.dart';
|
||||||
import '../widgets/common/error_retry_widget.dart';
|
import '../widgets/common/error_retry_widget.dart';
|
||||||
|
;
|
||||||
|
|
||||||
// CR-006: NotifierProviderでオンボーディングチェック状態を管理(グローバル変数を削除)
|
// CR-006: NotifierProviderでオンボーディングチェック状態を管理(グローバル変数を削除)
|
||||||
class HasCheckedOnboardingNotifier extends Notifier<bool> {
|
class HasCheckedOnboardingNotifier extends Notifier<bool> {
|
||||||
|
|
|
||||||
|
|
@ -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<LicenseScreen> createState() => _LicenseScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LicenseScreenState extends ConsumerState<LicenseScreen> {
|
||||||
|
final _keyController = TextEditingController();
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_keyController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<AppColors>()!.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<AppColors>()!;
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.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/theme_provider.dart'; // Access userProfileProvider
|
||||||
import '../providers/navigation_provider.dart'; // Track current tab index
|
import '../providers/navigation_provider.dart'; // Track current tab index
|
||||||
import '../utils/translations.dart'; // Translation helper
|
import '../utils/translations.dart'; // Translation helper
|
||||||
|
|
@ -100,6 +101,13 @@ class _MainScreenState extends ConsumerState<MainScreen> {
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
child: const Text('閉じる'),
|
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<MainScreen> {
|
||||||
});
|
});
|
||||||
|
|
||||||
final userProfile = ref.watch(userProfileProvider);
|
final userProfile = ref.watch(userProfileProvider);
|
||||||
|
final isPro = ref.watch(isProProvider);
|
||||||
// isBusinessApp=false(消費者向けビルド)では店舗モードを完全に無効化
|
// isBusinessApp=false(消費者向けビルド)では店舗モードを完全に無効化
|
||||||
final isBusiness = isBusinessApp && userProfile.isBusinessMode;
|
final isBusiness = isBusinessApp && userProfile.isBusinessMode;
|
||||||
final t = Translations(userProfile.locale); // Translation helper
|
final t = Translations(userProfile.locale); // Translation helper
|
||||||
|
|
||||||
// Define Screens for each mode
|
// Define Screens for each mode
|
||||||
// Lite版のPro限定タブは表示されないようにダミー画面を配置
|
// Pro版でないタブはダミー画面を配置(タップ時にダイアログで対応)
|
||||||
// (タップ時にダイアログで対応するため、画面遷移は発生しない)
|
|
||||||
final List<Widget> screens = isBusiness
|
final List<Widget> screens = isBusiness
|
||||||
? [
|
? [
|
||||||
const HomeScreen(), // Inventory Management
|
const HomeScreen(), // Inventory Management
|
||||||
isProVersion ? const InstaSupportScreen() : const HomeScreen(), // Instagram Support (Pro only)
|
isPro ? const InstaSupportScreen() : const HomeScreen(), // Instagram Support (Pro only)
|
||||||
isProVersion ? const AnalyticsScreen() : const HomeScreen(), // Analytics (Pro only)
|
isPro ? const AnalyticsScreen() : const HomeScreen(), // Analytics (Pro only)
|
||||||
const ShopSettingsScreen(), // Shop Settings
|
const ShopSettingsScreen(), // Shop Settings
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
const HomeScreen(), // My Sake List
|
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 SommelierScreen(), // Sommelier
|
||||||
const BreweryMapScreen(), // Map
|
const BreweryMapScreen(), // Map
|
||||||
const SoulScreen(), // MyPage/Settings
|
const SoulScreen(), // MyPage/Settings
|
||||||
];
|
];
|
||||||
|
|
||||||
// Define Navigation Items (with translation)
|
// Define Navigation Items (with translation)
|
||||||
// Lite版では王冠バッジを表示
|
// Pro版でない場合は王冠バッジを表示
|
||||||
final List<NavigationDestination> destinations = isBusiness
|
final List<NavigationDestination> destinations = isBusiness
|
||||||
? [
|
? [
|
||||||
NavigationDestination(
|
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'],
|
label: t['home'],
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
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'],
|
label: t['promo'],
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
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'],
|
label: t['analytics'],
|
||||||
),
|
),
|
||||||
NavigationDestination(icon: const Icon(LucideIcons.store), label: t['shop']),
|
NavigationDestination(icon: const Icon(LucideIcons.store), label: t['shop']),
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
NavigationDestination(
|
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'],
|
label: t['home'],
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
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'],
|
label: t['scan'],
|
||||||
),
|
),
|
||||||
NavigationDestination(icon: const Icon(LucideIcons.sparkles), label: t['sommelier']),
|
NavigationDestination(icon: const Icon(LucideIcons.sparkles), label: t['sommelier']),
|
||||||
|
|
@ -194,8 +202,8 @@ class _MainScreenState extends ConsumerState<MainScreen> {
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: _currentIndex,
|
selectedIndex: _currentIndex,
|
||||||
onDestinationSelected: (index) {
|
onDestinationSelected: (index) {
|
||||||
// Lite版でPro限定タブをタップした場合はダイアログを表示
|
// Pro版でない場合にPro限定タブをタップしたらダイアログを表示
|
||||||
if (!isProVersion) {
|
if (!isPro) {
|
||||||
if (isBusiness) {
|
if (isBusiness) {
|
||||||
// ビジネスモード: Instagram (index 1) と Analytics (index 2) がPro限定
|
// ビジネスモード: Instagram (index 1) と Analytics (index 2) がPro限定
|
||||||
if (index == 1) {
|
if (index == 1) {
|
||||||
|
|
|
||||||
|
|
@ -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<LicenseStatus> 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<bool> hasLicenseKey() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return (prefs.getString(_prefLicenseKey) ?? '').isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ライセンスをリセット(デバッグ用)
|
||||||
|
static Future<void> 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<LicenseStatus> _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<String, dynamic>;
|
||||||
|
|
||||||
|
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<void> _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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,6 +54,15 @@ class _OtherSettingsSectionState extends ConsumerState<OtherSettingsSection> {
|
||||||
color: appColors.surfaceSubtle,
|
color: appColors.surfaceSubtle,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
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) ...[
|
if (widget.showBusinessMode) ...[
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(LucideIcons.store, color: appColors.warning),
|
leading: Icon(LucideIcons.store, color: appColors.warning),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1,13 +1,25 @@
|
||||||
# Gemini API Key
|
# ============================================================
|
||||||
|
# Ponshu Room Proxy Server — 環境変数設定例
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# [必須] Gemini API Key
|
||||||
GEMINI_API_KEY=your_gemini_api_key_here
|
GEMINI_API_KEY=your_gemini_api_key_here
|
||||||
|
|
||||||
# Proxy Authentication Token (recommended for security)
|
# [必須] Proxy認証トークン (生成: openssl rand -hex 32)
|
||||||
# Generate a random string: openssl rand -hex 32
|
|
||||||
PROXY_AUTH_TOKEN=your_secure_random_token_here
|
PROXY_AUTH_TOKEN=your_secure_random_token_here
|
||||||
|
|
||||||
# Daily request limit per device
|
# [任意] 1デバイスあたりの日次リクエスト上限
|
||||||
DAILY_LIMIT=50
|
DAILY_LIMIT=50
|
||||||
|
|
||||||
# Redis connection settings (default values for Docker Compose)
|
# Redis接続設定 (Docker Compose デフォルト値)
|
||||||
REDIS_HOST=redis
|
REDIS_HOST=redis
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# サポート連絡先 (ライセンス有効化画面で表示)
|
||||||
|
APP_SUPPORT_EMAIL=support@posimai.soar-enrich.com
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 注意: Stripe/Resendの処理はメインサーバー (server.js) で行います
|
||||||
|
# Stripe Webhook → server.js → ライセンスキー生成 → Resendでメール送信
|
||||||
|
# このプロキシサーバーはライセンス検証 (/license/validate) のみ担当
|
||||||
|
# ============================================================
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ 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');
|
const { createClient } = require('redis');
|
||||||
|
const crypto = require('crypto');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const app = express();
|
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_HOST = process.env.REDIS_HOST || 'localhost';
|
||||||
const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10);
|
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({
|
const redisClient = createClient({
|
||||||
socket: {
|
socket: {
|
||||||
host: REDIS_HOST,
|
host: REDIS_HOST,
|
||||||
|
|
@ -30,22 +33,30 @@ redisClient.on('connect', () => {
|
||||||
console.log(`[Redis] Connected to ${REDIS_HOST}:${REDIS_PORT}`);
|
console.log(`[Redis] Connected to ${REDIS_HOST}:${REDIS_PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize Redis connection
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await redisClient.connect();
|
await redisClient.connect();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Redis] Failed to connect:', 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) {
|
function authMiddleware(req, res, next) {
|
||||||
if (!AUTH_TOKEN) {
|
if (!AUTH_TOKEN) {
|
||||||
// If no token configured, skip auth (backward compatibility)
|
console.error('[Auth] FATAL: PROXY_AUTH_TOKEN is not set.');
|
||||||
console.warn('[Auth] WARNING: PROXY_AUTH_TOKEN is not set. Authentication disabled.');
|
return res.status(503).json({ success: false, error: 'Server misconfigured: authentication token not set' });
|
||||||
return next();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers['authorization'];
|
||||||
|
|
@ -54,7 +65,7 @@ function authMiddleware(req, res, next) {
|
||||||
return res.status(401).json({ success: false, error: 'Authentication required' });
|
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) {
|
if (token !== AUTH_TOKEN) {
|
||||||
console.log(`[Auth] Rejected: Invalid token`);
|
console.log(`[Auth] Rejected: Invalid token`);
|
||||||
return res.status(403).json({ success: false, error: 'Invalid authentication token' });
|
return res.status(403).json({ success: false, error: 'Invalid authentication token' });
|
||||||
|
|
@ -63,84 +74,55 @@ function authMiddleware(req, res, next) {
|
||||||
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(bodyParser.json({ limit: '10mb' }));
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
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);
|
authMiddleware(req, res, next);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Gemini Client with JSON response configuration
|
// ========== Helper Functions ==========
|
||||||
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: 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 (Redis-based)
|
|
||||||
async function checkRateLimit(deviceId) {
|
async function checkRateLimit(deviceId) {
|
||||||
const today = getTodayString();
|
const today = getTodayString();
|
||||||
const redisKey = `usage:${deviceId}:${today}`;
|
const redisKey = `usage:${deviceId}:${today}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get current usage count
|
|
||||||
const currentCount = await redisClient.get(redisKey);
|
const currentCount = await redisClient.get(redisKey);
|
||||||
const count = currentCount ? parseInt(currentCount, 10) : 0;
|
const count = currentCount ? parseInt(currentCount, 10) : 0;
|
||||||
const remaining = DAILY_LIMIT - count;
|
const remaining = DAILY_LIMIT - count;
|
||||||
|
|
||||||
return {
|
return { allowed: remaining > 0, current: count, limit: DAILY_LIMIT, remaining, redisKey };
|
||||||
allowed: remaining > 0,
|
|
||||||
current: count,
|
|
||||||
limit: DAILY_LIMIT,
|
|
||||||
remaining: remaining,
|
|
||||||
redisKey: redisKey
|
|
||||||
};
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Redis] Error checking rate limit:', 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) {
|
async function incrementUsage(deviceId) {
|
||||||
const today = getTodayString();
|
const today = getTodayString();
|
||||||
const redisKey = `usage:${deviceId}:${today}`;
|
const redisKey = `usage:${deviceId}:${today}`;
|
||||||
|
|
||||||
try {
|
const newCount = await redisClient.incr(redisKey);
|
||||||
// Increment count
|
|
||||||
const newCount = await redisClient.incr(redisKey);
|
|
||||||
|
|
||||||
// Set expiration to end of day (86400 seconds = 24 hours)
|
const now = new Date();
|
||||||
const now = new Date();
|
const midnight = new Date(now);
|
||||||
const midnight = new Date(now);
|
midnight.setHours(24, 0, 0, 0);
|
||||||
midnight.setHours(24, 0, 0, 0);
|
const secondsUntilMidnight = Math.floor((midnight - now) / 1000);
|
||||||
const secondsUntilMidnight = Math.floor((midnight - now) / 1000);
|
await redisClient.expire(redisKey, secondsUntilMidnight);
|
||||||
|
|
||||||
await redisClient.expire(redisKey, secondsUntilMidnight);
|
return newCount;
|
||||||
|
|
||||||
return newCount;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Redis] Error incrementing usage:', err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Endpoint (authentication enforced by global middleware)
|
// ========== API Endpoints ==========
|
||||||
|
|
||||||
|
// 既存: AI解析 (認証必須)
|
||||||
app.post('/analyze', async (req, res) => {
|
app.post('/analyze', async (req, res) => {
|
||||||
const { device_id, images, prompt } = req.body;
|
const { device_id, images, prompt } = req.body;
|
||||||
|
|
||||||
|
|
@ -149,7 +131,6 @@ app.post('/analyze', async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Check Rate Limit (Redis-based)
|
|
||||||
const limitStatus = await checkRateLimit(device_id);
|
const limitStatus = await checkRateLimit(device_id);
|
||||||
if (!limitStatus.allowed) {
|
if (!limitStatus.allowed) {
|
||||||
console.log(`[Limit Reached] Device: ${device_id} (${limitStatus.current}/${limitStatus.limit})`);
|
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 => ({
|
const imageParts = (images || []).map(base64 => ({
|
||||||
inlineData: {
|
inlineData: { data: base64, mimeType: "image/jpeg" }
|
||||||
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 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)}`);
|
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
|
|
||||||
console.log('[Debug] Attempting to parse raw text as JSON');
|
|
||||||
try {
|
try {
|
||||||
jsonData = JSON.parse(text);
|
jsonData = JSON.parse(text);
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
|
|
@ -195,38 +167,111 @@ app.post('/analyze', async (req, res) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Increment Usage (Redis-based)
|
|
||||||
const newCount = await incrementUsage(device_id);
|
const newCount = await incrementUsage(device_id);
|
||||||
console.log(`[Success] Device: ${device_id} | New Count: ${newCount}/${DAILY_LIMIT}`);
|
console.log(`[Success] Device: ${device_id} | New Count: ${newCount}/${DAILY_LIMIT}`);
|
||||||
|
|
||||||
// 5. Send Response
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: jsonData,
|
data: jsonData,
|
||||||
usage: {
|
usage: { today: newCount, limit: DAILY_LIMIT }
|
||||||
today: newCount,
|
|
||||||
limit: DAILY_LIMIT
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Error] Gemini API or Redis Error:', error);
|
console.error('[Error] Gemini API or Redis Error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({ success: false, error: error.message || 'Internal Server Error' });
|
||||||
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) => {
|
app.get('/health', (req, res) => {
|
||||||
res.send('OK');
|
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', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Proxy Server running on port ${PORT}`);
|
console.log(`[Server] Ponshu Room Proxy running on port ${PORT}`);
|
||||||
if (!API_KEY) console.error('WARNING: GEMINI_API_KEY is not set!');
|
console.log(`[Server] Auth: Bearer Token enabled`);
|
||||||
if (!AUTH_TOKEN) console.error('WARNING: PROXY_AUTH_TOKEN is not set! Authentication is disabled.');
|
console.log(`[Server] Daily Limit: ${DAILY_LIMIT} requests per device`);
|
||||||
else console.log('Authentication: Bearer Token enabled');
|
console.log(`[Server] License validation: enabled`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue