import 'dart:convert'; import 'package:flutter/material.dart'; import '../presentation/pages/auth/captcha_challenge_page.dart'; import '../presentation/widgets/toast_helper.dart'; import '../services/auth_service.dart'; import '../services/server_service.dart'; import 'api_service.dart'; /// 验证码服务(单例) /// /// 管理验证码的加载、状态和 UI 构建,供登录/注册/忘记密码页面共享。 class CaptchaService { CaptchaService._internal(); static final CaptchaService _instance = CaptchaService._internal(); static CaptchaService get instance => _instance; final TextEditingController captchaController = TextEditingController(); String? _captchaType; String? _recaptchaSiteKey; String? _turnstileSiteKey; String? _capInstanceUrl; String? _capSiteKey; String? _capAssetServer; String? _captchaImage; String? _captchaTicket; String? _captchaToken; bool _isLoadingCaptcha = false; bool get isLoadingCaptcha => _isLoadingCaptcha; String? get captchaImage => _captchaImage; String? get captchaTicket => _captchaTicket; String? get captchaToken => _captchaToken; bool get isWebCaptcha => captchaWebConfig != null; CaptchaWebConfig? get captchaWebConfig { final type = _normalizedCaptchaType; if (type == 'turnstile' && _turnstileSiteKey != null && _turnstileSiteKey!.isNotEmpty) { return CaptchaWebConfig.turnstile( siteKey: _turnstileSiteKey!, displayName: 'Cloudflare Turnstile', ); } if (type == 'recaptcha' && _recaptchaSiteKey != null && _recaptchaSiteKey!.isNotEmpty) { return CaptchaWebConfig.recaptchaV2( siteKey: _recaptchaSiteKey!, displayName: 'reCAPTCHA V2', ); } if (type == 'cap' && _capInstanceUrl != null && _capInstanceUrl!.isNotEmpty && _capSiteKey != null && _capSiteKey!.isNotEmpty) { return CaptchaWebConfig.cap( instanceUrl: _capInstanceUrl!, siteKey: _capSiteKey!, assetServer: _capAssetServer, displayName: 'Cap', ); } return null; } String get _normalizedCaptchaType { final raw = (_captchaType ?? '').trim().toLowerCase(); if (raw == 'recaptcha_v2' || raw == 'recaptchav2' || raw == 'google' || raw == 'google_recaptcha' || raw == 'google-recaptcha') { return 'recaptcha'; } if (raw == 'cloudflare_turnstile' || raw == 'cloudflare-turnstile') { return 'turnstile'; } if (raw == 'image' || raw == 'graphic' || raw == 'captcha') { return 'normal'; } return raw; } /// 加载验证码配置和图片 Future loadCaptcha(String baseUrl) async { if (_isLoadingCaptcha) return; _isLoadingCaptcha = true; try { await ApiService.instance.setBaseUrl(baseUrl); Map config = {}; try { config = await AuthService.instance.getBasicSiteConfig().timeout( const Duration(seconds: 10), ); } catch (_) {} final captchaType = _normalizeCaptchaType( (config['captcha_type'] ?? config['captchaType'] ?? config['captcha']) ?.toString(), ); final recaptchaKey = _firstNonEmptyString(config, const [ 'captcha_ReCaptchaKey', 'captcha_re_captcha_key', 'captchaReCaptchaKey', 'recaptcha_site_key', 'recaptchaSiteKey', 'recaptcha_key', 'reCaptchaKey', ]); final turnstileSiteKey = _firstNonEmptyString(config, const [ 'turnstile_site_id', 'turnstileSiteId', 'turnstile_site_key', 'turnstileSiteKey', ]); final capInstanceUrl = _firstNonEmptyString(config, const [ 'captcha_cap_instance_url', 'captchaCapInstanceUrl', 'cap_instance_url', 'capInstanceUrl', ]); final capSiteKey = _firstNonEmptyString(config, const [ 'captcha_cap_site_key', 'captchaCapSiteKey', 'cap_site_key', 'capSiteKey', ]); final capAssetServer = _firstNonEmptyString(config, const [ 'captcha_cap_asset_server', 'captchaCapAssetServer', 'cap_asset_server', 'capAssetServer', ]); final isExternalCaptcha = captchaType == 'turnstile' || captchaType == 'recaptcha' || captchaType == 'cap'; if (isExternalCaptcha) { _captchaType = captchaType; _recaptchaSiteKey = recaptchaKey; _turnstileSiteKey = turnstileSiteKey; _capInstanceUrl = capInstanceUrl; _capSiteKey = capSiteKey; _capAssetServer = capAssetServer; _captchaToken = null; _captchaImage = null; _captchaTicket = null; captchaController.clear(); return; } final captcha = await AuthService.instance.getCaptcha(); _captchaType = captchaType.isEmpty ? 'normal' : captchaType; _recaptchaSiteKey = null; _turnstileSiteKey = null; _capInstanceUrl = null; _capSiteKey = null; _capAssetServer = null; _captchaToken = null; _captchaImage = captcha['image']; _captchaTicket = captcha['ticket']; captchaController.clear(); } catch (_) { clearCaptcha(); } finally { _isLoadingCaptcha = false; } } /// 重新加载图形验证码图片 Future refreshCaptcha() async { final server = ServerService.instance.currentServer; if (server == null) return; await loadCaptcha(server.baseUrl); } /// 跳转到 Web 验证码页面 Future openCaptchaChallenge(BuildContext context) async { final server = ServerService.instance.currentServer; final config = captchaWebConfig; if (server == null || config == null) { ToastHelper.failure('验证码配置无效'); return; } final token = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => CaptchaChallengePage(config: config, baseUrl: server.baseUrl), ), ); if (token != null && token.isNotEmpty) { _captchaToken = token; ToastHelper.success('人机验证完成'); } } /// 清空所有验证码状态 void clearCaptcha() { _captchaType = null; _recaptchaSiteKey = null; _turnstileSiteKey = null; _capInstanceUrl = null; _capSiteKey = null; _capAssetServer = null; _captchaToken = null; _captchaImage = null; _captchaTicket = null; captchaController.clear(); } /// 获取验证码参数(用于提交登录/注册/忘记密码请求) /// /// 返回 `{captcha: ..., ticket: ...}` 或空 Map(无需验证码时)。 Map getCaptchaParams() { if (isWebCaptcha) { if (_captchaToken == null || _captchaToken!.isEmpty) return {}; return {'captcha': _captchaToken!, 'ticket': _captchaToken!}; } final userInput = captchaController.text.trim(); if (userInput.isEmpty && (_captchaTicket == null || _captchaTicket!.isEmpty)) { return {}; } return {'captcha': userInput, 'ticket': _captchaTicket ?? ''}; } /// Web 验证码是否已通过 bool get isWebCaptchaVerified => !isWebCaptcha || (_captchaToken != null && _captchaToken!.isNotEmpty); /// 构建验证码输入 Widget Widget buildCaptchaInput(BuildContext context) { if (isWebCaptcha) { final config = captchaWebConfig; final displayName = config?.displayName ?? '人机验证'; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ OutlinedButton.icon( onPressed: _isLoadingCaptcha ? null : () => openCaptchaChallenge(context), icon: Icon( _captchaToken == null ? Icons.verified_user_outlined : Icons.verified, ), label: Text( _captchaToken == null ? '点击完成 $displayName' : '$displayName 已完成,点击重新验证', ), ), const SizedBox(height: 8), Text( '当前验证码类型:$displayName', style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor), ), ], ); } Widget captchaPreview; if (_isLoadingCaptcha) { captchaPreview = const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ); } else if (_captchaImage != null && _captchaImage!.isNotEmpty) { try { final base64Part = _captchaImage!.contains(',') ? _captchaImage!.split(',').last : _captchaImage!; captchaPreview = Image.memory( base64Decode(base64Part), fit: BoxFit.contain, gaplessPlayback: true, ); } catch (_) { captchaPreview = const Text('刷新'); } } else { captchaPreview = const Text('刷新'); } return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TextFormField( controller: captchaController, textInputAction: TextInputAction.done, decoration: const InputDecoration( labelText: '验证码', hintText: '请输入验证码', prefixIcon: Icon(Icons.verified_user_outlined), ), validator: (value) { final needCaptcha = _captchaTicket != null && _captchaTicket!.isNotEmpty; if (needCaptcha && (value == null || value.trim().isEmpty)) { return '请输入验证码'; } return null; }, ), ), const SizedBox(width: 12), InkWell( onTap: _isLoadingCaptcha ? null : refreshCaptcha, borderRadius: BorderRadius.circular(8), child: Container( width: 130, height: 56, alignment: Alignment.center, padding: const EdgeInsets.all(4), decoration: BoxDecoration( border: Border.all(color: Theme.of(context).dividerColor), borderRadius: BorderRadius.circular(8), ), child: captchaPreview, ), ), ], ); } String _normalizeCaptchaType(String? rawType) { final value = (rawType ?? '').trim().toLowerCase(); if (value.isEmpty) return 'normal'; if (value == 'image' || value == 'graphic' || value == 'captcha') { return 'normal'; } if (value == 'recaptcha_v2' || value == 'recaptchav2' || value == 'google' || value == 'google_recaptcha' || value == 'google-recaptcha') { return 'recaptcha'; } if (value == 'cloudflare_turnstile' || value == 'cloudflare-turnstile') { return 'turnstile'; } return value; } String? _firstNonEmptyString(Map source, List keys) { for (final key in keys) { final value = source[key]; if (value == null) continue; final text = value.toString().trim(); if (text.isNotEmpty) return text; } return null; } }