Files
app/lib/services/captcha_service.dart
T
gongyun 1d9e0a831f
Build Android Release / Build Android (push) Failing after 7s
Android Feature Optimization and Updates
2026-05-21 10:31:43 +08:00

396 lines
11 KiB
Dart

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