ponshu-room-lite/lib/services/license_service.dart

173 lines
6.6 KiB
Dart
Raw Normal View History

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;
// オンライン時は _validateKeyWithServer が常に上書きするため、
// _getCachedStatus はオフライン時専用のフォールバックとして動作する。
//
// TTL 判定_cacheValidSeconds = 24h:
// - free / offline は期限切れで free にフォールバック
// - pro : 購入者をオフライン時に締め出さないため永続扱い
// - revoked: 不正防止を優先するため永続扱い
// (将来 TTL を設けたい場合は isNoExpiryStatus を条件分岐ごと差し替える)
if (cachedAt != null) {
final age = DateTime.now().difference(DateTime.parse(cachedAt));
final isNoExpiryStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.name;
if (age.inSeconds > _cacheValidSeconds && !isNoExpiryStatus) {
return LicenseStatus.free;
}
}
return LicenseStatus.values.firstWhere(
(s) => s.name == cached,
orElse: () => LicenseStatus.free,
);
}
}