Files
app/lib/services/captcha_service.dart
T
2026-05-27 19:19:11 +08:00

503 lines
15 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 'dart:io';
import 'package:flutter/foundation.dart';
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;
// 代理配置(仅 Windows 桌面端)
CaptchaProxyConfig? _proxyConfig;
bool get _isDesktop => !kIsWeb && (Platform.isWindows || Platform.isLinux);
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<void> loadCaptcha(String baseUrl) async {
if (_isLoadingCaptcha) return;
_isLoadingCaptcha = true;
try {
await ApiService.instance.setBaseUrl(baseUrl);
Map<String, dynamic> config = <String, dynamic>{};
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<void> refreshCaptcha() async {
final server = ServerService.instance.currentServer;
if (server == null) return;
await loadCaptcha(server.baseUrl);
}
/// 跳转到 Web 验证码页面
Future<void> openCaptchaChallenge(BuildContext context, {VoidCallback? onVerified}) async {
final server = ServerService.instance.currentServer;
final config = captchaWebConfig;
if (server == null || config == null) {
ToastHelper.failure('验证码配置无效');
return;
}
final token = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => CaptchaChallengePage(
config: config,
baseUrl: server.baseUrl,
proxyConfig: _isDesktop ? _proxyConfig : null,
),
),
);
if (token != null && token.isNotEmpty) {
_captchaToken = token;
ToastHelper.success('人机验证完成');
onVerified?.call();
}
}
/// 清空所有验证码状态
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<String, String> 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 StatefulBuilder(
builder: (context, setState) {
final hasProxy = _proxyConfig != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
OutlinedButton.icon(
onPressed: _isLoadingCaptcha ? null : () async {
await openCaptchaChallenge(context);
setState(() {});
},
onLongPress: _isDesktop && Platform.isWindows
? () => _showProxyDialog(context, setState)
: null,
icon: Icon(
_captchaToken == null
? Icons.verified_user_outlined
: Icons.verified,
),
label: Text(
_captchaToken == null
? '点击完成 $displayName'
: '$displayName 已完成,点击重新验证',
),
),
const SizedBox(height: 8),
Text(
_isDesktop && Platform.isWindows
? '当前验证码类型:$displayName${hasProxy ? ' (代理: $_proxyConfig)' : ''}\n网络问题验证失败可长按上方按钮可以设置代理(仅windows)'
: '当前验证码类型:$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<String, dynamic> source, List<String> 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;
}
/// Windows 桌面端长按弹出代理设置对话框
void _showProxyDialog(BuildContext context, StateSetter setState) {
final hostCtrl = TextEditingController(text: _proxyConfig?.host ?? '');
final portCtrl = TextEditingController(text: _proxyConfig?.port.toString() ?? '');
showDialog(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('WebView 代理设置'),
content: SizedBox(
width: 320,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'仅支持无认证代理(HTTP/SOCKS5',
style: TextStyle(fontSize: 12, color: Theme.of(ctx).hintColor),
),
const SizedBox(height: 16),
TextFormField(
controller: hostCtrl,
decoration: const InputDecoration(
labelText: '代理地址',
hintText: '127.0.0.1',
prefixIcon: Icon(Icons.dns_outlined),
),
),
const SizedBox(height: 12),
TextFormField(
controller: portCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '端口',
hintText: '7890',
prefixIcon: Icon(Icons.numbers),
),
),
],
),
),
actions: [
TextButton(
onPressed: () {
_proxyConfig = null;
setState(() {});
Navigator.of(ctx).pop();
ToastHelper.success('已清除代理配置');
},
child: const Text('清除'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
FilledButton(
onPressed: () {
final host = hostCtrl.text.trim();
final port = int.tryParse(portCtrl.text.trim()) ?? 0;
if (host.isEmpty || port <= 0) {
ToastHelper.failure('请输入有效的代理地址和端口');
return;
}
_proxyConfig = CaptchaProxyConfig(host: host, port: port);
setState(() {});
Navigator.of(ctx).pop();
ToastHelper.success('代理已设置: $host:$port');
},
child: const Text('确定'),
),
],
);
},
);
}
}