Compare commits
12 Commits
2b90756417
...
f8ceb0bf02
| Author | SHA1 | Date |
|---|---|---|
|
|
f8ceb0bf02 | |
|
|
ba5660c1cb | |
|
|
402c6b6448 | |
|
|
675e67e3c1 | |
|
|
c7168e831c | |
|
|
d23ee8ed77 | |
|
|
f3d6a92799 | |
|
|
f229ff6b4b | |
|
|
7b3249791e | |
|
|
659e81628a | |
|
|
5311241fe5 | |
|
|
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,15 +7,11 @@ 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版かを判定するビルド時フラグ
|
||||
///
|
||||
/// ビルドコマンド:
|
||||
/// - Pro版: flutter build apk --release --dart-define=IS_PRO_VERSION=true
|
||||
/// - Lite版: flutter build apk --release --dart-define=IS_PRO_VERSION=false
|
||||
///
|
||||
/// デフォルトはfalse(Lite版) ※ponshu_room_liteディレクトリのため
|
||||
/// ビルド時Pro解放フラグ(現在未使用 — 実行時ライセンスはisProProviderで管理)
|
||||
/// 将来的に削除予定。isProProvider (license_provider.dart) を使うこと。
|
||||
const bool isProVersion = bool.fromEnvironment('IS_PRO_VERSION', defaultValue: false);
|
||||
|
||||
/// 店舗向けビルドかどうかを判定するビルド時フラグ
|
||||
|
|
@ -111,6 +107,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,
|
||||
);
|
||||
});
|
||||
|
|
@ -135,9 +135,7 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
|
|||
_buildMBTIDiagnosisSection(context, userProfile, sakeList),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// さけのわ おすすめ
|
||||
_buildSectionHeader(context, 'さけのわ おすすめ', LucideIcons.trendingUp),
|
||||
const SizedBox(height: 8),
|
||||
// さけのわ おすすめ(各ウィジェットが独自ヘッダーを持つため親ヘッダーは不要)
|
||||
const SakenowaNewRecommendationSection(displayCount: 5),
|
||||
const SizedBox(height: 16),
|
||||
const SakenowaRankingSection(displayCount: 10),
|
||||
|
|
@ -224,13 +222,13 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
|
|||
color: appColors.brandAccent.withValues(alpha: 0.05),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Subtle Sake Emoji
|
||||
Positioned(
|
||||
left: 20,
|
||||
top: 20,
|
||||
child: Opacity(
|
||||
opacity: 0.3, // Subtle
|
||||
opacity: 0.3,
|
||||
child: const Text(
|
||||
'🍶',
|
||||
style: TextStyle(fontSize: 40),
|
||||
|
|
@ -239,7 +237,7 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
|
|||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 12.0), // Optimized for チラ見せ effect
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
children: [
|
||||
// 1. Header (Name & Rank) with unified help icon
|
||||
|
|
@ -353,11 +351,32 @@ class _SommelierScreenState extends ConsumerState<SommelierScreen> {
|
|||
// --- MBTI Diagnosis Logic ---
|
||||
Widget _buildMBTIDiagnosisSection(BuildContext context, UserProfile userProfile, List<SakeItem> sakeList) {
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
color: appColors.surfaceSubtle,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: isDark
|
||||
? null
|
||||
: Border.all(color: appColors.divider.withValues(alpha: 0.5), width: 1),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: isDark
|
||||
? [const Color(0xFF2C2C2E), const Color(0xFF1C1C1E)]
|
||||
: [appColors.surfaceSubtle, appColors.surfaceElevated],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: isDark
|
||||
? Colors.black.withValues(alpha: 0.5)
|
||||
: Colors.black.withValues(alpha: 0.08),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
'すべてのPro機能をご利用いただけます。\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) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import '../../../models/sake_item.dart';
|
|||
import '../../../providers/theme_provider.dart';
|
||||
import '../../../services/mbti_compatibility_service.dart';
|
||||
import '../../../theme/app_colors.dart';
|
||||
import '../../../main.dart'; // For isProVersion
|
||||
import '../../../providers/license_provider.dart';
|
||||
|
||||
/// MBTI酒向スタンプセクション
|
||||
///
|
||||
|
|
@ -22,13 +22,14 @@ class SakeMbtiStampSection extends ConsumerWidget {
|
|||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
final isPro = ref.watch(isProProvider);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isProVersion
|
||||
color: isPro
|
||||
? appColors.brandPrimary.withValues(alpha: 0.3)
|
||||
: appColors.divider,
|
||||
style: BorderStyle.solid,
|
||||
|
|
@ -37,7 +38,7 @@ class SakeMbtiStampSection extends ConsumerWidget {
|
|||
borderRadius: BorderRadius.circular(16),
|
||||
color: Theme.of(context).cardColor.withValues(alpha: 0.5),
|
||||
),
|
||||
child: isProVersion
|
||||
child: isPro
|
||||
? _buildProContent(context, ref, appColors)
|
||||
: _buildLiteContent(context, appColors),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import '../widgets/sake_detail/sake_detail_memo.dart';
|
|||
import '../widgets/sake_detail/sake_detail_specs.dart';
|
||||
import 'sake_detail/widgets/sake_photo_edit_modal.dart';
|
||||
import 'sake_detail/sections/sake_mbti_stamp_section.dart';
|
||||
import '../main.dart' show isProVersion;
|
||||
import '../providers/license_provider.dart';
|
||||
import 'sake_detail/sections/sake_basic_info_section.dart';
|
||||
import 'sake_detail/widgets/sake_detail_sliver_app_bar.dart';
|
||||
import '../services/mbti_compatibility_service.dart';
|
||||
|
|
@ -83,6 +83,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
final isPro = ref.watch(isProProvider);
|
||||
|
||||
// スマートレコメンド
|
||||
final allSakeAsync = ref.watch(allSakeItemsProvider);
|
||||
|
|
@ -254,7 +255,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
|
|||
],
|
||||
|
||||
// MBTI Diagnostic Stamp Section (Pro only)
|
||||
if (isProVersion) ...[
|
||||
if (isPro) ...[
|
||||
SakeMbtiStampSection(sake: _sake),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -72,5 +72,11 @@ class Secrets {
|
|||
return local.SecretsLocal.geminiApiKey;
|
||||
}
|
||||
|
||||
/// Posimai メインサーバーのベースURL(ライセンス検証に使用)
|
||||
static const String posimaiBaseUrl = String.fromEnvironment(
|
||||
'POSIMAI_BASE_URL',
|
||||
defaultValue: 'https://api.soar-enrich.com',
|
||||
);
|
||||
|
||||
// static const String driveClientId = String.fromEnvironment('DRIVE_CLIENT_ID', defaultValue: '');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,54 +2,71 @@ import 'dart:convert';
|
|||
import 'package:crypto/crypto.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// デバイスID取得サービス
|
||||
/// レート制限のためのデバイス識別に使用
|
||||
/// レート制限・ライセンス管理のためのデバイス識別に使用
|
||||
///
|
||||
/// ## 安定性の保証
|
||||
/// - Android: ANDROID_ID をハッシュ化して使用(Factory Reset まで不変)
|
||||
/// - iOS: SharedPreferences に UUID を永続化(identifierForVendor は
|
||||
/// 全ベンダーアプリ削除後の再インストールで変わるため不使用)
|
||||
/// - その他/エラー時: SharedPreferences に UUID を永続化
|
||||
class DeviceService {
|
||||
static final DeviceInfoPlugin _deviceInfo = DeviceInfoPlugin();
|
||||
static String? _cachedDeviceId;
|
||||
|
||||
static const _prefKey = 'ponshu_device_id';
|
||||
|
||||
/// デバイス固有のIDを取得(SHA256ハッシュ化)
|
||||
static Future<String> getDeviceId() async {
|
||||
// キャッシュがあれば返す
|
||||
if (_cachedDeviceId != null) {
|
||||
if (_cachedDeviceId != null) return _cachedDeviceId!;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// SharedPreferencesに永続化済みならそれを使う(再インストール後も同じIDを返す)
|
||||
final stored = prefs.getString(_prefKey);
|
||||
if (stored != null && stored.isNotEmpty) {
|
||||
_cachedDeviceId = stored;
|
||||
debugPrint('[Device] Using persisted device ID: ${stored.substring(0, 8)}...');
|
||||
return _cachedDeviceId!;
|
||||
}
|
||||
|
||||
// 初回: ハードウェアIDから生成してSharedPreferencesに保存
|
||||
final id = await _generateAndPersist(prefs);
|
||||
_cachedDeviceId = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
static Future<String> _generateAndPersist(SharedPreferences prefs) async {
|
||||
String deviceIdentifier;
|
||||
|
||||
try {
|
||||
String deviceIdentifier;
|
||||
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
final androidInfo = await _deviceInfo.androidInfo;
|
||||
// Android IDを使用(アプリ再インストールでも同じIDを維持)
|
||||
deviceIdentifier = androidInfo.id;
|
||||
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
final iosInfo = await _deviceInfo.iosInfo;
|
||||
// identifierForVendor(アプリ再インストールで変わる可能性あり)
|
||||
deviceIdentifier = iosInfo.identifierForVendor ?? 'unknown-ios';
|
||||
final info = await _deviceInfo.androidInfo;
|
||||
// ANDROID_ID: アプリ再インストールでは変わらない。Factory Resetで変わる。
|
||||
deviceIdentifier = 'android-${info.id}';
|
||||
} else {
|
||||
// その他のプラットフォーム
|
||||
deviceIdentifier = 'unknown-platform';
|
||||
// iOS / その他: UUIDを生成して永続化
|
||||
// identifierForVendor は全ベンダーアプリ削除後の再インストールで変わるため不使用
|
||||
deviceIdentifier = 'uuid-${const Uuid().v4()}';
|
||||
}
|
||||
|
||||
// SHA256ハッシュ化(64文字の固定長文字列)
|
||||
final bytes = utf8.encode(deviceIdentifier);
|
||||
final digest = sha256.convert(bytes);
|
||||
_cachedDeviceId = digest.toString();
|
||||
|
||||
debugPrint('Device ID (hashed): ${_cachedDeviceId!.substring(0, 8)}...');
|
||||
|
||||
return _cachedDeviceId!;
|
||||
} catch (e) {
|
||||
debugPrint('Error getting device ID: $e');
|
||||
// エラー時はランダムなIDを生成(セッション中は同じIDを使用)
|
||||
_cachedDeviceId = sha256.convert(utf8.encode('fallback-${DateTime.now().millisecondsSinceEpoch}')).toString();
|
||||
return _cachedDeviceId!;
|
||||
debugPrint('[Device] Failed to get hardware ID, using UUID fallback: $e');
|
||||
deviceIdentifier = 'fallback-${const Uuid().v4()}';
|
||||
}
|
||||
|
||||
final id = sha256.convert(utf8.encode(deviceIdentifier)).toString();
|
||||
await prefs.setString(_prefKey, id);
|
||||
debugPrint('[Device] Generated and persisted device ID: ${id.substring(0, 8)}...');
|
||||
return id;
|
||||
}
|
||||
|
||||
/// デバイス情報をリセット(テスト用)
|
||||
static void reset() {
|
||||
static Future<void> reset() async {
|
||||
_cachedDeviceId = null;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_prefKey);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,10 @@ class GeminiService {
|
|||
"manufacturingYearMonth": "2023.10"
|
||||
}
|
||||
|
||||
値が不明な場合は null または 適切な推測値を入れてください。特にtasteStatsは必ず1-5の数値で埋めてください。
|
||||
★重要な指示:
|
||||
- "name"(銘柄名)と "brand"(蔵元名)は、ラベルに明記されている文字を**そのまま**使用してください。知識から補完・推測・変更しないでください。例:ラベルに「東魁」とあれば「東魁盛」に変えない。
|
||||
- tasteStatsは必ず1-5の数値で埋めてください。
|
||||
- その他の値が不明な場合は null または 適切な推測値を入れてください。
|
||||
''';
|
||||
|
||||
return _callProxyApi(
|
||||
|
|
@ -54,82 +57,6 @@ class GeminiService {
|
|||
);
|
||||
}
|
||||
|
||||
/// OCRテキストと画像のハイブリッド解析
|
||||
Future<SakeAnalysisResult> analyzeSakeHybrid(String extractedText, List<String> imagePaths) async {
|
||||
final prompt = '''
|
||||
あなたは日本酒の専門家(ソムリエ)です。
|
||||
|
||||
以下のOCR抽出テキストは参考情報です(誤字・脱落あり)。
|
||||
OCRテキストはあくまで補助的なヒントとして扱い、添付の画像を優先して全項目を必ず埋めてください。
|
||||
|
||||
OCRテキスト(参考のみ):
|
||||
"""
|
||||
$extractedText
|
||||
"""
|
||||
|
||||
添付の日本酒ラベル画像を分析し、以下のJSON形式で情報を抽出してください。
|
||||
|
||||
{
|
||||
"name": "銘柄名",
|
||||
"brand": "蔵元名",
|
||||
"prefecture": "都道府県名",
|
||||
"type": "特定名称(純米大吟醸など)",
|
||||
"description": "味や特徴の魅力的な説明文(100文字程度)",
|
||||
"catchCopy": "短いキャッチコピー(20文字以内)",
|
||||
"confidenceScore": 80,
|
||||
"flavorTags": ["フルーティー", "辛口", "華やか"],
|
||||
"tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3},
|
||||
"alcoholContent": 15.0,
|
||||
"polishingRatio": 50,
|
||||
"sakeMeterValue": 3.0,
|
||||
"riceVariety": "山田錦",
|
||||
"yeast": "きょうかい9号",
|
||||
"manufacturingYearMonth": "2023.10"
|
||||
}
|
||||
|
||||
★重要な指示:
|
||||
- tasteStats(香り、甘味、酸味、苦味、ボディ)は必ず1-5の整数で埋めてください。不明な場合は3を設定してください。
|
||||
- alcoholContent, polishingRatio, sakeMeterValue などの詳細項目も、画像から読み取れる場合は必ず設定してください。
|
||||
- 値が不明な場合は null または 適切な推測値を入れてください。
|
||||
''';
|
||||
|
||||
return _callProxyApi(imagePaths: imagePaths, customPrompt: prompt);
|
||||
}
|
||||
|
||||
/// テキストのみの解析 (画像なし)
|
||||
Future<SakeAnalysisResult> analyzeSakeText(String extractedText) async {
|
||||
final prompt = '''
|
||||
以下のOCRで抽出された日本酒ラベルのテキスト情報を分析してください。
|
||||
誤字やノイズが含まれることが多いですが、文脈から積極的に正しい情報を推測・補完してください。
|
||||
|
||||
抽出テキスト:
|
||||
"""
|
||||
$extractedText
|
||||
"""
|
||||
|
||||
以下の情報をJSON形式で返してください:
|
||||
{
|
||||
"name": "銘柄名",
|
||||
"brand": "蔵元名",
|
||||
"prefecture": "都道府県名",
|
||||
"type": "特定名称",
|
||||
"description": "特徴(100文字)",
|
||||
"catchCopy": "キャッチコピー(20文字)",
|
||||
"confidenceScore": 0-100,
|
||||
"flavorTags": ["タグ"],
|
||||
"tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3},
|
||||
"alcoholContent": 15.5,
|
||||
"polishingRatio": 50,
|
||||
"sakeMeterValue": 3.0,
|
||||
"riceVariety": "山田錦",
|
||||
"yeast": "きょうかい9号",
|
||||
"manufacturingYearMonth": "2023.10"
|
||||
}
|
||||
''';
|
||||
|
||||
return _callProxyApi(imagePaths: [], customPrompt: prompt);
|
||||
}
|
||||
|
||||
/// 共通実装: ProxyへのAPIコール
|
||||
Future<SakeAnalysisResult> _callProxyApi({
|
||||
required List<String> imagePaths,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
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 (6バイト = 12hex文字, 大文字)
|
||||
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);
|
||||
if (status == LicenseStatus.offline) {
|
||||
// ネットワーク不通: キャッシュを上書きせずに返す
|
||||
debugPrint('[License] Server unreachable, using cache');
|
||||
return _getCachedStatus(prefs);
|
||||
}
|
||||
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サポートにお問い合わせください。');
|
||||
}
|
||||
|
||||
if (status == LicenseStatus.offline) {
|
||||
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);
|
||||
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.posimaiBaseUrl}/api/ponshu/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.free;
|
||||
|
||||
} 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にフォールバック
|
||||
// pro と revoked は期限切れにしない(proは購入者を締め出さない、revokedは誤って復活させない)
|
||||
if (cachedAt != null) {
|
||||
final age = DateTime.now().difference(DateTime.parse(cachedAt));
|
||||
final isPermanentStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.name;
|
||||
if (age.inSeconds > _cacheValidSeconds && !isPermanentStatus) {
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.21+32
|
||||
version: 1.0.28+35
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.1
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
param([string]$ApkDir = "")
|
||||
|
||||
# Load .env.local
|
||||
$envFile = Join-Path $PSScriptRoot ".env.local"
|
||||
if (-not (Test-Path $envFile)) { Write-Error ".env.local not found"; exit 1 }
|
||||
Get-Content $envFile | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if ($line -and -not $line.StartsWith('#') -and $line.Contains('=')) {
|
||||
$idx = $line.IndexOf('=')
|
||||
$key = $line.Substring(0, $idx).Trim()
|
||||
$val = $line.Substring($idx + 1).Trim()
|
||||
[System.Environment]::SetEnvironmentVariable($key, $val)
|
||||
}
|
||||
}
|
||||
|
||||
$GITEA_TOKEN = $env:GITEA_TOKEN
|
||||
$GITEA_BASE_URL = $env:GITEA_BASE_URL
|
||||
$GITEA_OWNER = $env:GITEA_OWNER
|
||||
$GITEA_REPO = $env:GITEA_REPO
|
||||
|
||||
if (-not $GITEA_TOKEN) { Write-Error "GITEA_TOKEN not set in .env.local"; exit 1 }
|
||||
|
||||
# Find latest APK build folder
|
||||
if (-not $ApkDir) {
|
||||
$root = Join-Path $PSScriptRoot "build\apk_releases"
|
||||
if (-not (Test-Path $root)) { Write-Error "build\apk_releases not found"; exit 1 }
|
||||
$ApkDir = Get-ChildItem $root -Directory |
|
||||
Sort-Object Name -Descending |
|
||||
Select-Object -First 1 -ExpandProperty FullName
|
||||
}
|
||||
if (-not (Test-Path $ApkDir)) { Write-Error "APK folder not found: $ApkDir"; exit 1 }
|
||||
|
||||
$apkFiles = Get-ChildItem $ApkDir -Filter "*.apk"
|
||||
if ($apkFiles.Count -eq 0) { Write-Error "No APK files in: $ApkDir"; exit 1 }
|
||||
|
||||
# Read version from pubspec.yaml
|
||||
$publine = Get-Content (Join-Path $PSScriptRoot "pubspec.yaml") | Where-Object { $_ -match "^version:" } | Select-Object -First 1
|
||||
$version = if ($publine -match "version:\s*(\S+)") { $Matches[1].Split("+")[0] } else { "1.0.0" }
|
||||
$buildNum = if ($publine -match "version:\s*[^+]+\+(\S+)") { $Matches[1] } else { "0" }
|
||||
$dateStr = Get-Date -Format "yyyy-MM-dd"
|
||||
$tagName = "v$version"
|
||||
$relName = "Ponshu Room $version ($dateStr)"
|
||||
|
||||
# APKの埋め込みバージョンと pubspec.yaml が一致するか確認
|
||||
Write-Host " Checking APK version matches pubspec ($version+$buildNum)..." -ForegroundColor Gray
|
||||
$firstApk = $apkFiles | Select-Object -First 1
|
||||
$apkInfo = & flutter.bat --version 2>$null
|
||||
# aapt2 でバージョン確認(利用可能な場合)
|
||||
$aapt2 = Get-ChildItem "$env:LOCALAPPDATA\Android\Sdk\build-tools" -Recurse -Filter "aapt2.exe" -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName
|
||||
if ($aapt2) {
|
||||
$apkDump = & $aapt2 dump badging $firstApk.FullName 2>$null | Select-String "versionName|versionCode"
|
||||
$apkVersion = if ($apkDump -match "versionName='([^']+)'") { $Matches[1] } else { $null }
|
||||
$apkBuild = if ($apkDump -match "versionCode='([^']+)'") { $Matches[1] } else { $null }
|
||||
if ($apkVersion -and $apkVersion -ne $version) {
|
||||
Write-Host ""
|
||||
Write-Host " [ERROR] APK version mismatch!" -ForegroundColor Red
|
||||
Write-Host " APK contains : $apkVersion (build $apkBuild)" -ForegroundColor Red
|
||||
Write-Host " pubspec.yaml : $version+$buildNum" -ForegroundColor Red
|
||||
Write-Host " -> Rebuild APKs after version bump, then retry." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
Write-Host " OK: APK version = $apkVersion (build $apkBuild)" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " (aapt2 not found, skipping version check)" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
Write-Host "================================================" -ForegroundColor Cyan
|
||||
Write-Host " Ponshu Room - Gitea Auto Release" -ForegroundColor Cyan
|
||||
Write-Host "================================================" -ForegroundColor Cyan
|
||||
Write-Host " Server : $GITEA_BASE_URL" -ForegroundColor Gray
|
||||
Write-Host " Repo : $GITEA_OWNER/$GITEA_REPO" -ForegroundColor Gray
|
||||
Write-Host " Tag : $tagName" -ForegroundColor Gray
|
||||
Write-Host " APKs : $($apkFiles.Count) files" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
$authHeader = @{ "Authorization" = "token $GITEA_TOKEN" }
|
||||
|
||||
# Step 1: Delete existing release if found
|
||||
Write-Host "[1/3] Checking existing release..." -ForegroundColor Yellow
|
||||
try {
|
||||
$existing = Invoke-RestMethod `
|
||||
-Uri "$GITEA_BASE_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases/tags/$tagName" `
|
||||
-Headers $authHeader -Method Get -ErrorAction Stop
|
||||
Write-Host " Found existing (ID: $($existing.id)). Deleting..." -ForegroundColor Yellow
|
||||
Invoke-RestMethod `
|
||||
-Uri "$GITEA_BASE_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases/$($existing.id)" `
|
||||
-Headers $authHeader -Method Delete | Out-Null
|
||||
} catch {
|
||||
Write-Host " No existing release. Creating new." -ForegroundColor Gray
|
||||
}
|
||||
|
||||
# Step 2: Create release
|
||||
Write-Host "[2/3] Creating release..." -ForegroundColor Yellow
|
||||
|
||||
$releaseNotes = @(
|
||||
"## Ponshu Room $version ($dateStr)",
|
||||
"",
|
||||
"### APK Files",
|
||||
"- ponshu_room_consumer_maita.apk : Maita",
|
||||
"- ponshu_room_consumer_eiji.apk : Eiji",
|
||||
"",
|
||||
"### Install",
|
||||
"1. Download the APK for your name",
|
||||
"2. On Android: Settings > Security > Allow unknown sources",
|
||||
"3. Tap the downloaded APK to install",
|
||||
"",
|
||||
"Requires Android 8.0+ (API 26+)"
|
||||
) -join "`n"
|
||||
|
||||
$body = @{
|
||||
tag_name = $tagName
|
||||
name = $relName
|
||||
body = $releaseNotes
|
||||
draft = $false
|
||||
prerelease = $false
|
||||
} | ConvertTo-Json -Depth 3
|
||||
|
||||
$release = Invoke-RestMethod `
|
||||
-Uri "$GITEA_BASE_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" `
|
||||
-Headers $authHeader `
|
||||
-Method Post `
|
||||
-ContentType "application/json" `
|
||||
-Body $body
|
||||
|
||||
Write-Host " OK - Release ID: $($release.id)" -ForegroundColor Green
|
||||
|
||||
# Step 3: Upload APKs with multipart/form-data
|
||||
Write-Host "[3/3] Uploading APK files..." -ForegroundColor Yellow
|
||||
$downloadUrls = @{}
|
||||
foreach ($apk in $apkFiles) {
|
||||
$mb = [math]::Round($apk.Length / 1MB, 1)
|
||||
Write-Host " Uploading: $($apk.Name) ($mb MB)..." -ForegroundColor Gray
|
||||
|
||||
$boundary = [System.Guid]::NewGuid().ToString()
|
||||
$uploadUrl = "$GITEA_BASE_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases/$($release.id)/assets?name=$($apk.Name)"
|
||||
|
||||
$fileBytes = [System.IO.File]::ReadAllBytes($apk.FullName)
|
||||
$enc = [System.Text.Encoding]::UTF8
|
||||
|
||||
$bodyParts = [System.Collections.Generic.List[byte]]::new()
|
||||
$header = "--$boundary`r`nContent-Disposition: form-data; name=`"attachment`"; filename=`"$($apk.Name)`"`r`nContent-Type: application/octet-stream`r`n`r`n"
|
||||
$bodyParts.AddRange($enc.GetBytes($header))
|
||||
$bodyParts.AddRange($fileBytes)
|
||||
$footer = "`r`n--$boundary--`r`n"
|
||||
$bodyParts.AddRange($enc.GetBytes($footer))
|
||||
$bodyArray = $bodyParts.ToArray()
|
||||
|
||||
$wc = New-Object System.Net.WebClient
|
||||
$wc.Headers.Add("Authorization", "token $GITEA_TOKEN")
|
||||
$wc.Headers.Add("Content-Type", "multipart/form-data; boundary=$boundary")
|
||||
$result = $wc.UploadData($uploadUrl, "POST", $bodyArray)
|
||||
$wc.Dispose()
|
||||
|
||||
$json = [System.Text.Encoding]::UTF8.GetString($result) | ConvertFrom-Json
|
||||
$downloadUrls[$apk.BaseName] = $json.browser_download_url
|
||||
Write-Host " OK: $($json.browser_download_url)" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Step 4: Update releases.json for Vercel
|
||||
Write-Host ""
|
||||
Write-Host "[4/4] Updating releases.json..." -ForegroundColor Yellow
|
||||
$tailscaleBase = "$GITEA_BASE_URL".Replace("http://100.76.7.3:3000", "https://posimai-lab.tail72e846.ts.net")
|
||||
$relJson = @{
|
||||
version = $tagName
|
||||
name = $relName
|
||||
date = $dateStr
|
||||
apks = @{
|
||||
maita = @{
|
||||
lite = @{ filename = "ponshu_room_consumer_maita.apk"; url = "$tailscaleBase/mai/ponshu-room-lite/releases/download/$tagName/ponshu_room_consumer_maita.apk"; size_mb = [math]::Round(($apkFiles | Where-Object { $_.Name -eq "ponshu_room_consumer_maita.apk" }).Length / 1MB) }
|
||||
}
|
||||
eiji = @{
|
||||
lite = @{ filename = "ponshu_room_consumer_eiji.apk"; url = "$tailscaleBase/mai/ponshu-room-lite/releases/download/$tagName/ponshu_room_consumer_eiji.apk"; size_mb = [math]::Round(($apkFiles | Where-Object { $_.Name -eq "ponshu_room_consumer_eiji.apk" }).Length / 1MB) }
|
||||
}
|
||||
}
|
||||
} | ConvertTo-Json -Depth 5
|
||||
|
||||
$relJsonPath = Join-Path $PSScriptRoot "web\download\releases.json"
|
||||
[System.IO.File]::WriteAllText($relJsonPath, $relJson, [System.Text.Encoding]::UTF8)
|
||||
Write-Host " OK: releases.json updated" -ForegroundColor Green
|
||||
|
||||
# Step 5: Vercel redeploy
|
||||
Write-Host ""
|
||||
Write-Host "[5/5] Deploying to Vercel..." -ForegroundColor Yellow
|
||||
$vercelDir = Join-Path $PSScriptRoot "web\download"
|
||||
Push-Location $vercelDir
|
||||
try {
|
||||
$deployOutput = vercel --prod --yes 2>&1 | Out-String
|
||||
$deployOutput -split "`n" | Where-Object { $_ -match "Production:|Error" } | Write-Host
|
||||
|
||||
# Extract production URL from output
|
||||
$prodUrl = ($deployOutput -split "`n" | Where-Object { $_ -match "Production: https://" } | Select-Object -First 1) -replace ".*Production: (https://[^\s]+).*", '$1'
|
||||
|
||||
if ($prodUrl) {
|
||||
Write-Host " Setting alias ponshu-room.vercel.app..." -ForegroundColor Gray
|
||||
vercel alias set $prodUrl ponshu-room.vercel.app 2>&1 | Out-Null
|
||||
Write-Host " OK: Alias set" -ForegroundColor Green
|
||||
}
|
||||
} catch {
|
||||
Write-Host " Warning: Vercel deploy or alias failed" -ForegroundColor Yellow
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "================================================" -ForegroundColor Green
|
||||
Write-Host " All done!" -ForegroundColor Green
|
||||
Write-Host "================================================" -ForegroundColor Green
|
||||
Write-Host " Gitea : $GITEA_BASE_URL/$GITEA_OWNER/$GITEA_REPO/releases/tag/$tagName"
|
||||
Write-Host " Download : https://ponshu-room.vercel.app"
|
||||
Write-Host ""
|
||||
|
|
@ -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'];
|
||||
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,39 @@ 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.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`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
{
|
||||
"date": "2026-04-06",
|
||||
"name": "Ponshu Room 1.0.25 (2026-04-10)",
|
||||
"version": "v1.0.25",
|
||||
{
|
||||
"date": "2026-04-11",
|
||||
"name": "Ponshu Room 1.0.28 (2026-04-11)",
|
||||
"version": "v1.0.28",
|
||||
"apks": {
|
||||
"eiji": {
|
||||
"lite": {
|
||||
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.25/ponshu_room_consumer_eiji.apk",
|
||||
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.28/ponshu_room_consumer_eiji.apk",
|
||||
"size_mb": 89,
|
||||
"filename": "ponshu_room_consumer_eiji.apk"
|
||||
}
|
||||
},
|
||||
"maita": {
|
||||
"lite": {
|
||||
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.25/ponshu_room_consumer_maita.apk",
|
||||
"url": "https://posimai-lab.tail72e846.ts.net/mai/ponshu-room-lite/releases/download/v1.0.28/ponshu_room_consumer_maita.apk",
|
||||
"size_mb": 89,
|
||||
"filename": "ponshu_room_consumer_maita.apk"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -57,17 +57,17 @@ a {
|
|||
.container {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
gap: var(--space-md);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ===== Hero ===== */
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding-top: var(--space-xl);
|
||||
padding-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.logo {
|
||||
|
|
@ -78,9 +78,9 @@ a {
|
|||
}
|
||||
|
||||
.sake-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--kohaku-gold);
|
||||
font-size: 48px;
|
||||
line-height: 1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
|
|
@ -97,55 +97,6 @@ a {
|
|||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* ===== Preview ===== */
|
||||
.preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.phone-frame {
|
||||
width: 200px;
|
||||
height: 400px;
|
||||
background: var(--sumi-black);
|
||||
border-radius: 32px;
|
||||
padding: 12px;
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(74, 59, 50, 0.25),
|
||||
inset 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.screen-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, var(--gray-100) 0%, var(--washi-white) 100%);
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.grid-preview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
aspect-ratio: 1;
|
||||
background: linear-gradient(145deg, var(--kohaku-gold), var(--kohaku-deep));
|
||||
border-radius: 8px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.grid-item:nth-child(2) { opacity: 0.4; }
|
||||
.grid-item:nth-child(3) { opacity: 0.8; }
|
||||
.grid-item:nth-child(4) { opacity: 0.5; }
|
||||
.grid-item:nth-child(5) { opacity: 0.7; }
|
||||
.grid-item:nth-child(6) { opacity: 0.3; }
|
||||
|
||||
/* ===== Download ===== */
|
||||
.download {
|
||||
display: flex;
|
||||
|
|
@ -258,18 +209,6 @@ a {
|
|||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* ===== Footer ===== */
|
||||
footer {
|
||||
margin-top: auto;
|
||||
text-align: center;
|
||||
padding: var(--space-lg) 0;
|
||||
}
|
||||
|
||||
footer p {
|
||||
font-size: 0.75rem;
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
/* ===== Dark Mode ===== */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
|
|
@ -281,14 +220,6 @@ footer p {
|
|||
--gray-600: #9E9A94;
|
||||
}
|
||||
|
||||
.phone-frame {
|
||||
background: #2A2A2A;
|
||||
}
|
||||
|
||||
.screen-placeholder {
|
||||
background: linear-gradient(135deg, #1E1E1E 0%, #2A2A2A 100%);
|
||||
}
|
||||
|
||||
.version-card {
|
||||
background: #1E1E1E;
|
||||
border-color: #2A2A2A;
|
||||
|
|
@ -312,11 +243,6 @@ footer p {
|
|||
/* ===== Responsive ===== */
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.phone-frame {
|
||||
width: 240px;
|
||||
height: 480px;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue