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

173 lines
6.6 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;
// オンライン時は _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,
);
}
}