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 '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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/pending_analysis_banner.dart';
|
||||
import '../widgets/common/error_retry_widget.dart';
|
||||
;
|
||||
|
||||
// CR-006: NotifierProviderでオンボーディングチェック状態を管理(グローバル変数を削除)
|
||||
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_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<MainScreen> {
|
|||
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<MainScreen> {
|
|||
});
|
||||
|
||||
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<Widget> 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<NavigationDestination> 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<MainScreen> {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
# 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) のみ担当
|
||||
# ============================================================
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue