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:
Ponshu Developer 2026-04-11 00:05:53 +09:00
parent 2b90756417
commit d47bb201ac
11 changed files with 690 additions and 107 deletions

43
.github/workflows/ios_build.yml vendored Normal file
View File

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

View File

@ -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(),
},
); );
} }
} }

View File

@ -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,
);
});

View File

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

View File

@ -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)),
],
),
),
],
),
);
}
}

View File

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

View File

@ -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,
);
}
}

View File

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

View File

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

View File

@ -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) のみ担当
# ============================================================

View File

@ -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`);
}); });