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

168 lines
6.3 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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